Merge pull request 'fix(backend): disable one-registration-per-IP by default' (#1) from main into dev
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -23,6 +23,8 @@ FRONTEND_ORIGINS=https://localhost,https://127.0.0.1
|
|||||||
# Keep false unless you intentionally want fail-open rate limits / captcha.
|
# Keep false unless you intentionally want fail-open rate limits / captcha.
|
||||||
DEV_MODE=false
|
DEV_MODE=false
|
||||||
SEED_CURRICULUM=false
|
SEED_CURRICULUM=false
|
||||||
|
# One registration per client IP. Keep false behind NAT / shared egress (default).
|
||||||
|
REGISTRATION_ENFORCE_IP=false
|
||||||
# METRICS_TOKEN=
|
# METRICS_TOKEN=
|
||||||
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1/128
|
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1/128
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ FRONTEND_ORIGINS=https://app.example.com
|
|||||||
|
|
||||||
DEV_MODE=false
|
DEV_MODE=false
|
||||||
SEED_CURRICULUM=false
|
SEED_CURRICULUM=false
|
||||||
|
# Keep false behind NAT until the edge forwards real client IPs.
|
||||||
|
REGISTRATION_ENFORCE_IP=false
|
||||||
METRICS_TOKEN=replace-with-metrics-scrape-token
|
METRICS_TOKEN=replace-with-metrics-scrape-token
|
||||||
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||||
|
|
||||||
|
|||||||
@@ -130,16 +130,8 @@ func (s *Server) handleRegisterInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := clientAddress(r)
|
ip, ok := s.registrationClientIP(w, r)
|
||||||
if ip == "" || ip == "unknown" {
|
if !ok {
|
||||||
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبتنام ممکن نشد")
|
|
||||||
return
|
|
||||||
} else if taken {
|
|
||||||
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبتنام انجام شده است")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ func TestInviteRevoke(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInviteDuplicatePhoneAndIP(t *testing.T) {
|
func TestInviteDuplicatePhoneAndIP(t *testing.T) {
|
||||||
|
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
|
||||||
e := setup(t)
|
e := setup(t)
|
||||||
inv := createInvite(t, e, 5, "", nil)
|
inv := createInvite(t, e, 5, "", nil)
|
||||||
token := inv["token"].(string)
|
token := inv["token"].(string)
|
||||||
|
|||||||
@@ -27,6 +27,29 @@ func (s *Server) registrationRequireWhitelist(r *http.Request) bool {
|
|||||||
return s.settingTruthy(r, "registration_require_whitelist", true)
|
return s.settingTruthy(r, "registration_require_whitelist", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registrationClientIP returns the IP to bind to a registration when enforcement is on.
|
||||||
|
// When REGISTRATION_ENFORCE_IP is false (default), returns "" so NAT / shared egress is ignored.
|
||||||
|
func (s *Server) registrationClientIP(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
|
if s.cfg == nil || !s.cfg.RegistrationEnforceIP {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
ip := clientAddress(r)
|
||||||
|
if ip == "" || ip == "unknown" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
taken, err := s.store.HasRegistrationIP(r.Context(), ip)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبتنام ممکن نشد")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبتنام انجام شده است")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return ip, true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleRegisterStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRegisterStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"enabled": s.registrationEnabled(r),
|
"enabled": s.registrationEnabled(r),
|
||||||
@@ -83,16 +106,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := clientAddress(r)
|
ip, ok := s.registrationClientIP(w, r)
|
||||||
if ip == "" || ip == "unknown" {
|
if !ok {
|
||||||
writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبتنام ممکن نشد")
|
|
||||||
return
|
|
||||||
} else if taken {
|
|
||||||
writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبتنام انجام شده است")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ func TestRegisterDuplicatePhone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterSameIPRejected(t *testing.T) {
|
func TestRegisterSameIPRejected(t *testing.T) {
|
||||||
|
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
|
||||||
e := setup(t)
|
e := setup(t)
|
||||||
enableRegistration(t, e)
|
enableRegistration(t, e)
|
||||||
whitelistPhones(t, e, "09121110007", "09121110008")
|
whitelistPhones(t, e, "09121110007", "09121110008")
|
||||||
@@ -278,7 +279,27 @@ func TestRegisterSameIPRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterSameIPAllowedWhenEnforcementOff(t *testing.T) {
|
||||||
|
t.Setenv("REGISTRATION_ENFORCE_IP", "false")
|
||||||
|
e := setup(t)
|
||||||
|
enableRegistration(t, e)
|
||||||
|
whitelistPhones(t, e, "09121110007", "09121110008")
|
||||||
|
|
||||||
|
headers := map[string]string{"X-Forwarded-For": "203.0.113.40"}
|
||||||
|
code, body := e.doHeaders(t, "POST", "/api/auth/register", "", headers,
|
||||||
|
registerBody(t, e, "09121110007", "reg-ipoff1@test.dev", "regipoff1"))
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("first: %d %v", code, body)
|
||||||
|
}
|
||||||
|
code, body = e.doHeaders(t, "POST", "/api/auth/register", "", headers,
|
||||||
|
registerBody(t, e, "09121110008", "reg-ipoff2@test.dev", "regipoff2"))
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("same IP must be allowed when REGISTRATION_ENFORCE_IP=false: %d %v", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterUsesForwardedIP(t *testing.T) {
|
func TestRegisterUsesForwardedIP(t *testing.T) {
|
||||||
|
t.Setenv("REGISTRATION_ENFORCE_IP", "true")
|
||||||
e := setup(t)
|
e := setup(t)
|
||||||
enableRegistration(t, e)
|
enableRegistration(t, e)
|
||||||
whitelistPhones(t, e, "09121110009", "09121110010", "09121110011")
|
whitelistPhones(t, e, "09121110009", "09121110010", "09121110011")
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ type Config struct {
|
|||||||
PhysicalCertEnabled bool
|
PhysicalCertEnabled bool
|
||||||
PhysicalCertPriceIRR int
|
PhysicalCertPriceIRR int
|
||||||
PhysicalCertWindowDays int
|
PhysicalCertWindowDays int
|
||||||
|
// RegistrationEnforceIP limits one successful registration per client IP.
|
||||||
|
// Default false: behind NAT / shared egress the peer IP is not a real client identity.
|
||||||
|
RegistrationEnforceIP bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
@@ -140,6 +143,7 @@ func Load() *Config {
|
|||||||
PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true",
|
PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true",
|
||||||
PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000),
|
PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000),
|
||||||
PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30),
|
PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30),
|
||||||
|
RegistrationEnforceIP: getEnv("REGISTRATION_ENFORCE_IP", "false") == "true",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -344,16 +344,14 @@ func EvaluateInviteAvailability(inv *models.RegistrationInvite, now time.Time) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterInviteStudent creates a student, consumes the IP, and atomically consumes one invite seat.
|
// RegisterInviteStudent creates a student, optionally records the client IP, and consumes one invite seat.
|
||||||
|
// Empty ip skips one-registration-per-IP enforcement (NAT / shared egress).
|
||||||
func (s *Store) RegisterInviteStudent(
|
func (s *Store) RegisterInviteStudent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
inviteID int64,
|
inviteID int64,
|
||||||
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
||||||
) (*models.User, error) {
|
) (*models.User, error) {
|
||||||
ip = strings.TrimSpace(ip)
|
ip = strings.TrimSpace(ip)
|
||||||
if ip == "" {
|
|
||||||
return nil, errors.New("empty registration ip")
|
|
||||||
}
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -366,12 +364,14 @@ func (s *Store) RegisterInviteStudent(
|
|||||||
return nil, ErrPhoneBlacklisted
|
return nil, ErrPhoneBlacklisted
|
||||||
}
|
}
|
||||||
|
|
||||||
var taken bool
|
if ip != "" {
|
||||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
var taken bool
|
||||||
return nil, err
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
||||||
}
|
return nil, err
|
||||||
if taken {
|
}
|
||||||
return nil, ErrRegistrationIPTaken
|
if taken {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tag, err := tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
@@ -436,12 +436,14 @@ func (s *Store) RegisterInviteStudent(
|
|||||||
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if ip != "" {
|
||||||
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
if _, err := tx.Exec(ctx, `
|
||||||
if isUniqueViolation(err) {
|
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
||||||
return nil, ErrRegistrationIPTaken
|
if isUniqueViolation(err) {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -331,18 +331,15 @@ func (s *Store) HasRegistrationIP(ctx context.Context, ip string) (bool, error)
|
|||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPublicStudent creates a student and consumes the IP atomically.
|
// RegisterPublicStudent creates a student and optionally records the client IP.
|
||||||
// When requireWhitelist is true, the phone must be an available whitelist entry.
|
// When requireWhitelist is true, the phone must be an available whitelist entry.
|
||||||
// Concurrent same-IP registrations: one commits; the other rolls back on unique IP conflict.
|
// Empty ip skips one-registration-per-IP enforcement (NAT / shared egress).
|
||||||
func (s *Store) RegisterPublicStudent(
|
func (s *Store) RegisterPublicStudent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
||||||
requireWhitelist bool,
|
requireWhitelist bool,
|
||||||
) (*models.User, error) {
|
) (*models.User, error) {
|
||||||
ip = strings.TrimSpace(ip)
|
ip = strings.TrimSpace(ip)
|
||||||
if ip == "" {
|
|
||||||
return nil, errors.New("empty registration ip")
|
|
||||||
}
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -355,12 +352,14 @@ func (s *Store) RegisterPublicStudent(
|
|||||||
return nil, ErrPhoneBlacklisted
|
return nil, ErrPhoneBlacklisted
|
||||||
}
|
}
|
||||||
|
|
||||||
var taken bool
|
if ip != "" {
|
||||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
var taken bool
|
||||||
return nil, err
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
||||||
}
|
return nil, err
|
||||||
if taken {
|
}
|
||||||
return nil, ErrRegistrationIPTaken
|
if taken {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
row := tx.QueryRow(ctx, `
|
row := tx.QueryRow(ctx, `
|
||||||
@@ -380,12 +379,14 @@ func (s *Store) RegisterPublicStudent(
|
|||||||
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
if ip != "" {
|
||||||
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
if _, err := tx.Exec(ctx, `
|
||||||
if isUniqueViolation(err) {
|
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
||||||
return nil, ErrRegistrationIPTaken
|
if isUniqueViolation(err) {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ x-backend-env: &backend-env
|
|||||||
STORAGE_SECRET_KEY: ${RUSTFS_SECRET_KEY:?RUSTFS_SECRET_KEY must be set}
|
STORAGE_SECRET_KEY: ${RUSTFS_SECRET_KEY:?RUSTFS_SECRET_KEY must be set}
|
||||||
STORAGE_BUCKET: ${STORAGE_BUCKET:-pargar}
|
STORAGE_BUCKET: ${STORAGE_BUCKET:-pargar}
|
||||||
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
||||||
|
REGISTRATION_ENFORCE_IP: ${REGISTRATION_ENFORCE_IP:-false}
|
||||||
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ spec:
|
|||||||
value: {{ .Values.backend.env.devMode | quote }}
|
value: {{ .Values.backend.env.devMode | quote }}
|
||||||
- name: SEED_CURRICULUM
|
- name: SEED_CURRICULUM
|
||||||
value: {{ .Values.backend.env.seedCurriculum | quote }}
|
value: {{ .Values.backend.env.seedCurriculum | quote }}
|
||||||
|
- name: REGISTRATION_ENFORCE_IP
|
||||||
|
value: {{ .Values.backend.env.registrationEnforceIP | quote }}
|
||||||
- name: ADMIN_NAME
|
- name: ADMIN_NAME
|
||||||
value: {{ .Values.backend.admin.name | quote }}
|
value: {{ .Values.backend.admin.name | quote }}
|
||||||
- name: ADMIN_USERNAME
|
- name: ADMIN_USERNAME
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ backend:
|
|||||||
env:
|
env:
|
||||||
devMode: "false"
|
devMode: "false"
|
||||||
seedCurriculum: "false"
|
seedCurriculum: "false"
|
||||||
|
# false behind NAT / shared egress (peer IP is not a real client).
|
||||||
|
registrationEnforceIP: "false"
|
||||||
donationEnabled: "true"
|
donationEnabled: "true"
|
||||||
# Offline payment only — leave off until ops process is ready; enable in admin settings.
|
# Offline payment only — leave off until ops process is ready; enable in admin settings.
|
||||||
physicalCertEnabled: "false"
|
physicalCertEnabled: "false"
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ One of two patterns:
|
|||||||
|----------|-----------------|--------|
|
|----------|-----------------|--------|
|
||||||
| `DEV_MODE` | false | Open metrics without token, limited fail-open behavior |
|
| `DEV_MODE` | false | Open metrics without token, limited fail-open behavior |
|
||||||
| `SEED_CURRICULUM` | false (empty in DevMode → true) | Seed lessons/events |
|
| `SEED_CURRICULUM` | false (empty in DevMode → true) | Seed lessons/events |
|
||||||
|
| `REGISTRATION_ENFORCE_IP` | false | One registration per client IP; keep off behind NAT |
|
||||||
| `CAPTCHA_EXPOSE_ANSWER` | DevMode only | For tests; never in production |
|
| `CAPTCHA_EXPOSE_ANSWER` | DevMode only | For tests; never in production |
|
||||||
| `DONATION_ENABLED` | true | Support card |
|
| `DONATION_ENABLED` | true | Support card |
|
||||||
| `DONATION_NOTE` | Copy text | |
|
| `DONATION_NOTE` | Copy text | |
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
- Whitelist and blacklist are **mutually exclusive**; import resolves conflicts.
|
- Whitelist and blacklist are **mutually exclusive**; import resolves conflicts.
|
||||||
- A phone already tied to an account cannot re-enter either list as a fresh entry.
|
- A phone already tied to an account cannot re-enter either list as a fresh entry.
|
||||||
- One account per phone and per IP on registration paths.
|
- One account per phone. Optional one-account-per-IP (`REGISTRATION_ENFORCE_IP`, default off — unsuitable behind NAT).
|
||||||
- Blacklist blocks all registration paths (public + invite) and keeps a failed-attempt history tab.
|
- Blacklist blocks all registration paths (public + invite) and keeps a failed-attempt history tab.
|
||||||
|
|
||||||
## Certificates
|
## Certificates
|
||||||
|
|||||||
+5
-1
@@ -25,6 +25,10 @@ Public password recovery (`/api/auth/recovery/*` and `/recovery`) is disabled fo
|
|||||||
- Only peers in `TRUSTED_PROXY_CIDRS` are trusted for `X-Forwarded-*` / `X-Real-IP`.
|
- Only peers in `TRUSTED_PROXY_CIDRS` are trusted for `X-Forwarded-*` / `X-Real-IP`.
|
||||||
- CORS: `FRONTEND_ORIGINS` + `PUBLIC_URL` with `AllowCredentials: true`.
|
- CORS: `FRONTEND_ORIGINS` + `PUBLIC_URL` with `AllowCredentials: true`.
|
||||||
|
|
||||||
|
## Registration IP binding
|
||||||
|
|
||||||
|
`REGISTRATION_ENFORCE_IP` (default **false**) optionally limits one successful public/invite registration per client IP. Leave it off when the app sits behind NAT or shared egress and cannot see real client addresses. Enable only after the edge forwards trustworthy `X-Forwarded-For` / `X-Real-IP` via `TRUSTED_PROXY_CIDRS`.
|
||||||
|
|
||||||
## Rate limiting
|
## Rate limiting
|
||||||
|
|
||||||
| Scope | Approximate behavior |
|
| Scope | Approximate behavior |
|
||||||
@@ -34,7 +38,7 @@ Public password recovery (`/api/auth/recovery/*` and `/recovery`) is disabled fo
|
|||||||
| External chat export | ~10/min/IP |
|
| External chat export | ~10/min/IP |
|
||||||
| Per-user uploads | Separately limited |
|
| Per-user uploads | Separately limited |
|
||||||
|
|
||||||
`/api/health` is excluded from the global ceiling.
|
`/api/health` is excluded from the global ceiling. Note: per-IP rate limits still use the peer/forwarded address — configure trusted proxies when you need per-client fairness behind NAT.
|
||||||
|
|
||||||
## Uploads
|
## Uploads
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user