Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a8ec898
improve: refactor for the code
hardy-dev-infinilabs May 22, 2026
c02f2ea
docs: clarify login challenge helpers
hardy-dev-infinilabs May 26, 2026
efb67e7
chore: add file headers for security helpers
hardy-dev-infinilabs May 26, 2026
cec184f
feat(security): add http filter support for transport and replay options
Copilot May 26, 2026
cd5f34a
fixup(security): address review nits in security filter
Copilot May 26, 2026
b604cfa
fixup: remove unintended generated config change
Copilot May 26, 2026
5de4829
improve(security): clarify https requirement error message
Copilot May 26, 2026
d7bad6c
fixup: revert generated metadata file
Copilot May 26, 2026
5826ab9
feat: add native account challenge login
hardy-dev-infinilabs May 26, 2026
18578aa
test: document native challenge login flow
hardy-dev-infinilabs May 26, 2026
089f9e2
docs: expand challenge login comments
hardy-dev-infinilabs May 26, 2026
dd51e98
docs: annotate security route options
hardy-dev-infinilabs May 26, 2026
19b7a01
test: expand challenge security coverage
hardy-dev-infinilabs May 26, 2026
20d03bf
docs: clarify password challenge constants
hardy-dev-infinilabs May 26, 2026
7a180c9
docs: explain challenge login tests
hardy-dev-infinilabs May 26, 2026
c6c7b3f
feat: align native account sessions with console rollout\n\nCo-author…
hardy-dev-infinilabs May 26, 2026
1118e4b
feat: support shared account flow migration\n\nCo-authored-by: Copilo…
hardy-dev-infinilabs May 26, 2026
98aae58
Avoid panic in RBAC user flow
hardy-dev-infinilabs May 26, 2026
dd84ff2
Clean auth runtime panics
hardy-dev-infinilabs May 26, 2026
3ad0518
Inline RBAC account routes
hardy-dev-infinilabs May 26, 2026
4812153
Rehome account hook helpers
hardy-dev-infinilabs May 26, 2026
016ca11
Tighten account login semantics
hardy-dev-infinilabs May 26, 2026
80f5dae
Drop legacy claim aliases
hardy-dev-infinilabs May 26, 2026
69bfe44
Protect framework roles in use
hardy-dev-infinilabs May 26, 2026
f602206
Merge branch 'main' into pr/framework-login-challenge
medcl May 27, 2026
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
155 changes: 155 additions & 0 deletions core/api/security.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (C) INFINI Labs & INFINI LIMITED.
//
// The INFINI Framework is offered under the GNU Affero General Public License v3.0
// and as commercial software.
//
// For commercial licensing, contact us at:
// - Website: infinilabs.com
// - Email: hello@infini.ltd
//
// Open Source licensed under AGPL V3:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package api

import (
"net/http"
"strings"

httprouter "infini.sh/framework/core/api/router"
replaysecurity "infini.sh/framework/core/security/replay"
)

type SecureTransportOptions struct {
// TrustForwardHeaders allows HTTPS detection to honor reverse-proxy forwarding headers.
TrustForwardHeaders bool
}

const (
// FeatureRequireSecureTransport marks a UI handler as HTTPS-only when it is enforced by filters.
FeatureRequireSecureTransport = "feature_require_secure_transport"
// FeatureRequireReplayProtection marks a UI handler as requiring a valid replay nonce.
FeatureRequireReplayProtection = "feature_require_replay_protection"
// LabelTrustForwardHeaders stores whether HTTPS checks may trust reverse-proxy forwarding headers.
LabelTrustForwardHeaders = "label_trust_forward_headers"
)

// RequestUsesSecureTransport reports whether the request arrived over HTTPS directly or, when
// allowed, through a trusted reverse proxy that forwarded HTTPS metadata.
func RequestUsesSecureTransport(req *http.Request, options ...SecureTransportOptions) bool {
if req == nil {
return false
}
if req.TLS != nil {
return true
}

resolved := resolveSecureTransportOptions(options)
if !resolved.TrustForwardHeaders {
return false
}

for _, header := range []string{"X-Forwarded-Proto", "X-Forwarded-Protocol", "X-Url-Scheme"} {
if headerIndicatesHTTPS(req.Header.Get(header)) {
return true
}
}

if strings.EqualFold(strings.TrimSpace(req.Header.Get("X-Forwarded-Ssl")), "on") {
return true
}

return forwardedHeaderIndicatesHTTPS(req.Header.Get("Forwarded"))
}

// RequireSecureTransport wraps a handler so it rejects requests that do not resolve to HTTPS.
func (handler Handler) RequireSecureTransport(h httprouter.Handle, options ...SecureTransportOptions) httprouter.Handle {
resolved := resolveSecureTransportOptions(options)
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if !RequestUsesSecureTransport(r, resolved) {
handler.WriteError(w, "this endpoint requires HTTPS. use https:// directly or route through a trusted HTTPS reverse proxy", http.StatusUpgradeRequired)
return
}
h(w, r, ps)
}
}

// RequireSecureTransport wraps a handler with the default security handler implementation.
func RequireSecureTransport(h httprouter.Handle, options ...SecureTransportOptions) httprouter.Handle {
return Handler{}.RequireSecureTransport(h, options...)
}

// RequireReplayProtection wraps a handler so each request must present a valid replay nonce.
func (handler Handler) RequireReplayProtection(h httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if err := replaysecurity.ValidateAndConsumeReplayNonce(r); err != nil {
handler.WriteError(w, err.Error(), http.StatusUnauthorized)
return
}
h(w, r, ps)
}
}

// RequireReplayProtection wraps a handler with the default replay-protection implementation.
func RequireReplayProtection(h httprouter.Handle) httprouter.Handle {
return Handler{}.RequireReplayProtection(h)
}

// SecureTransportOption annotates a UI route so SecurityFilter can enforce HTTPS consistently.
func SecureTransportOption(options ...SecureTransportOptions) Option {
resolved := resolveSecureTransportOptions(options)
return func(o *HandlerOptions) {
Feature(FeatureRequireSecureTransport)(o)
Label(LabelTrustForwardHeaders, resolved.TrustForwardHeaders)(o)
}
}

// ReplayProtectionOption annotates a UI route so SecurityFilter enforces replay-nonce validation.
func ReplayProtectionOption() Option {
return Feature(FeatureRequireReplayProtection)
}

func resolveSecureTransportOptions(options []SecureTransportOptions) SecureTransportOptions {
if len(options) == 0 {
return SecureTransportOptions{}
}
return options[0]
}

func headerIndicatesHTTPS(value string) bool {
if value == "" {
return false
}
first := strings.TrimSpace(strings.Split(value, ",")[0])
return strings.EqualFold(first, "https")
}

func forwardedHeaderIndicatesHTTPS(value string) bool {
if value == "" {
return false
}

for _, forwardedValue := range strings.Split(value, ",") {
for _, token := range strings.Split(forwardedValue, ";") {
parts := strings.SplitN(strings.TrimSpace(token), "=", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "proto") {
continue
}
proto := strings.Trim(parts[1], "\"")
return strings.EqualFold(proto, "https")
}
}

return false
}
160 changes: 160 additions & 0 deletions core/api/security_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (C) INFINI Labs & INFINI LIMITED.
//
// The INFINI Framework is offered under the GNU Affero General Public License v3.0
// and as commercial software.
//
// For commercial licensing, contact us at:
// - Website: infinilabs.com
// - Email: hello@infini.ltd
//
// Open Source licensed under AGPL V3:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package api

import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"

httprouter "infini.sh/framework/core/api/router"
replaysecurity "infini.sh/framework/core/security/replay"
)

// The transport tests cover both direct TLS and trusted proxy headers because the
// security helpers are shared by embedded UI routes that may sit behind a proxy.
func TestRequestUsesSecureTransport(t *testing.T) {
tests := []struct {
name string
setup func(req *http.Request)
options []SecureTransportOptions
secure bool
}{
{
name: "tls request",
setup: func(req *http.Request) {
req.TLS = &tls.ConnectionState{}
},
secure: true,
},
{
name: "forwarded proto requires opt in",
setup: func(req *http.Request) {
req.Header.Set("X-Forwarded-Proto", "https")
},
secure: false,
},
{
name: "forwarded proto trusted when enabled",
setup: func(req *http.Request) {
req.Header.Set("X-Forwarded-Proto", "https")
},
options: []SecureTransportOptions{{TrustForwardHeaders: true}},
secure: true,
},
{
name: "plain http",
setup: func(req *http.Request) {},
secure: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "http://console.local/account/login", nil)
tt.setup(req)

if RequestUsesSecureTransport(req, tt.options...) != tt.secure {
t.Fatalf("expected secure=%v", tt.secure)
}
})
}
}

// The wrapper should fail fast before running the protected handler on plain HTTP.
func TestRequireSecureTransport(t *testing.T) {
handler := Handler{}
called := false
protected := handler.RequireSecureTransport(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
called = true
w.WriteHeader(http.StatusOK)
})

req := httptest.NewRequest(http.MethodPost, "http://console.local/account/login", nil)
resp := httptest.NewRecorder()

protected(resp, req, nil)

if called {
t.Fatal("expected insecure request to be blocked")
}
if resp.Code != http.StatusUpgradeRequired {
t.Fatalf("expected status %d, got %d", http.StatusUpgradeRequired, resp.Code)
}
}

// Replay-protected handlers should pass straight through once a matching nonce exists.
func TestRequireReplayProtection(t *testing.T) {
handler := Handler{}
req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil)
nonce, _, err := replaysecurity.IssueReplayNonce(req, http.MethodPost, "/account/login")
if err != nil {
t.Fatalf("issue replay nonce: %v", err)
}
req.Header.Set(replaysecurity.HeaderName, nonce)

called := false
protected := handler.RequireReplayProtection(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
called = true
w.WriteHeader(http.StatusOK)
})
resp := httptest.NewRecorder()

protected(resp, req, nil)

if !called {
t.Fatal("expected replay-protected handler to run")
}
if resp.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, resp.Code)
}
}

// Route options are later consumed by SecurityFilter, so the feature flag and labels
// must both be set when secure transport enforcement is requested declaratively.
func TestSecureTransportOption(t *testing.T) {
options := &HandlerOptions{}
SecureTransportOption(SecureTransportOptions{TrustForwardHeaders: true})(options)

if !options.Feature(FeatureRequireSecureTransport) {
t.Fatal("expected secure transport feature to be enabled")
}
if options.Labels == nil {
t.Fatal("expected labels to be initialized")
}
if v, ok := options.Labels[LabelTrustForwardHeaders].(bool); !ok || !v {
t.Fatalf("expected trust forward headers label to be true, got %#v", options.Labels[LabelTrustForwardHeaders])
}
}

// Replay protection uses a single feature flag because the filter reads no extra labels.
func TestReplayProtectionOption(t *testing.T) {
options := &HandlerOptions{}
ReplayProtectionOption()(options)

if !options.Feature(FeatureRequireReplayProtection) {
t.Fatal("expected replay protection feature to be enabled")
}
}
Loading
Loading