Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ JWT_SECRET=0000000000000000000000000000000000000000000000000000000000000000
SESSION_KEY=sessionkey
OSCTRL_USER=admin
OSCTRL_PASS=Changeme123!
GOLANG_VERSION=1.26.1
GOLANG_VERSION=1.26.3
OSCTRL_TLS_LOGGER=db
2 changes: 1 addition & 1 deletion .github/actions/build/binaries/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ inputs:
golang_version:
required: false
description: Define the version of golang to compile with
default: 1.26.1
default: 1.26.3

runs:
using: "composite"
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/test/binaries/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ inputs:
golang_version:
required: false
description: Define the version of golang to compile with
default: 1.26.1
default: 1.26.3

runs:
using: "composite"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build_and_test_main_merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ concurrency:
cancel-in-progress: false

env:
GOLANG_VERSION: 1.26.1
GOLANG_VERSION: 1.26.3

jobs:
validate:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build_and_test_pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:
cancel-in-progress: true

env:
GOLANG_VERSION: 1.26.1
GOLANG_VERSION: 1.26.3

jobs:
validate:
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/frontend-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: frontend-build
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build:
name: build SPA (lint + tests + bundle)
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: install
run: |
if [ -f package-lock.json ]; then
npm ci --no-audit --no-fund
else
npm install --no-audit --no-fund
fi
- name: typecheck
run: npm run check
- name: forbid dangerouslySetInnerHTML
# Every field originating from an osquery node is untrusted —
# the SPA's default JSX escaping is the only XSS gate today, so
# a future contributor adding `dangerouslySetInnerHTML` would
# silently break that invariant. Fail the build if it appears
# anywhere under src/. To intentionally introduce one, prefer
# a dedicated sanitizer and document the threat model alongside.
run: |
if grep -rn "dangerouslySetInnerHTML" src/; then
echo "::error::dangerouslySetInnerHTML is forbidden — node-originating fields must be JSX-escaped"
exit 1
fi
- name: tests
run: npm test
- name: build
run: npm run build
- name: upload bundle
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: osctrl-frontend-dist
path: frontend/dist/
retention-days: 7
2 changes: 1 addition & 1 deletion .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ permissions:
contents: read

env:
GOLANG_VERSION: 1.26.1
GOLANG_VERSION: 1.26.3

jobs:
golangci:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ concurrency:
cancel-in-progress: false

env:
GOLANG_VERSION: 1.26.1
GOLANG_VERSION: 1.26.3

jobs:
release:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,4 @@ tools/bruno/collection.bru
!CONTRIBUTING.md
!CHANGELOG.md
!SECURITY.md
!frontend/**/*.md
29 changes: 28 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ ADMIN_DIR = cmd/admin
ADMIN_NAME = osctrl-admin
ADMIN_CODE = ${ADMIN_DIR:=/*.go}

FRONTEND_DIR = frontend

API_DIR = cmd/api
API_NAME = osctrl-api
API_CODE = ${API_DIR:=/*.go}
Expand All @@ -27,7 +29,7 @@ DIST = dist
STATIC_ARGS = -ldflags "-linkmode external -extldflags -static"
BUILD_ARGS = -ldflags "-s -w -X main.buildCommit=$(shell git rev-parse HEAD) -X main.buildDate=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)"

.PHONY: build static clean tls admin cli api release release-build release-check release-init clean-dist
.PHONY: build static clean tls admin cli api release release-build release-check release-init clean-dist frontend frontend-install frontend-dev frontend-build frontend-test

# Build code according to caller OS and architecture
build:
Expand Down Expand Up @@ -75,6 +77,31 @@ cli:
cli-static:
go build $(BUILD_ARGS) $(STATIC_ARGS) -o $(OUTPUT)/$(CLI_NAME) -a $(CLI_CODE)

# ---------------------------------------------------------------------------
# React admin frontend (Vite + TypeScript SPA, served by nginx)
# ---------------------------------------------------------------------------

# Install JS deps (use ci when a lockfile is present, install otherwise).
frontend-install:
cd $(FRONTEND_DIR) && \
if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \
else npm install --no-audit --no-fund; fi

# Local dev server (Vite on :5173, proxies /api → :8081).
frontend-dev:
cd $(FRONTEND_DIR) && npm run dev

# Run vitest + tsc.
frontend-test:
cd $(FRONTEND_DIR) && npm test && npm run check

# Build the production bundle into frontend/dist/.
frontend-build:
cd $(FRONTEND_DIR) && npm run build

# One-shot: install + build (used by CI / Docker builds).
frontend: frontend-install frontend-build

# Clean the dist directory
clean-dist:
rm -rf $(DIST)
Expand Down
4 changes: 2 additions & 2 deletions cmd/admin/handlers/json-nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,14 @@ func (h *HandlersAdmin) JSONEnvironmentPagingHandler(w http.ResponseWriter, r *h
log.Err(err).Msg("error counting search nodes")
return
}
nodesSlice, err = h.Nodes.SearchByEnvPage(env.Name, searchValue, target, hours, start, length, colName, desc)
nodesSlice, err = h.Nodes.SearchByEnvPage(env.Name, searchValue, target, hours, start, length, colName, desc) //nolint:staticcheck // SA1019: intentional legacy admin caller; new SPA uses GetByEnvPaged
if err != nil {
log.Err(err).Msg("error searching nodes page")
return
}
} else {
filteredCount = totalCount
nodesSlice, err = h.Nodes.GetByEnvPage(env.Name, target, hours, start, length, colName, desc)
nodesSlice, err = h.Nodes.GetByEnvPage(env.Name, target, hours, start, length, colName, desc) //nolint:staticcheck // SA1019: intentional legacy admin caller; new SPA uses GetByEnvPaged
if err != nil {
log.Err(err).Msg("error getting nodes page")
return
Expand Down
2 changes: 1 addition & 1 deletion cmd/admin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ func osctrlAdminService() {
time.Sleep(time.Duration(flagParams.Redis.ConnRetry) * time.Second)
}
log.Info().Msg("Initialize users")
adminUsers = users.CreateUserManager(db.Conn, flagParams.JWT)
adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)
log.Info().Msg("Initialize tags")
tagsmgr = tags.CreateTagManager(db.Conn)
log.Info().Msg("Initialize environments")
Expand Down
122 changes: 114 additions & 8 deletions cmd/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package main

import (
"context"
"crypto/subtle"
"net/http"
"strings"

"github.com/jmpsec/osctrl/cmd/api/handlers"
"github.com/jmpsec/osctrl/pkg/config"
"github.com/jmpsec/osctrl/pkg/types"
"github.com/jmpsec/osctrl/pkg/utils"
"github.com/rs/zerolog/log"
)
Expand All @@ -16,14 +18,79 @@ const (
contextAPI string = "osctrl-api-context"
)

// Helper to extract token from header
// Cookie + header names — kept in sync with cmd/api/handlers/login.go.
const (
cookieNameToken = "osctrl_token"
cookieNameCSRF = "osctrl_csrf"
headerNameCSRF = "X-CSRF-Token"
)

// Helper to extract token from the Authorization header first (CLI clients),
// falling back to the SPA's HttpOnly osctrl_token cookie.
func extractHeaderToken(r *http.Request) string {
reqToken := r.Header.Get("Authorization")
splitToken := strings.Split(reqToken, "Bearer")
if len(splitToken) != 2 {
return ""
if v := r.Header.Get("Authorization"); v != "" {
splitToken := strings.Split(v, "Bearer")
if len(splitToken) == 2 {
if t := strings.TrimSpace(splitToken[1]); t != "" {
return t
}
}
}
if c, err := r.Cookie(cookieNameToken); err == nil {
return strings.TrimSpace(c.Value)
}
return ""
}

// mutatingMethods is the set of HTTP verbs that must carry a valid CSRF token.
// GET/HEAD/OPTIONS are read-only and exempt.
var mutatingMethods = map[string]bool{
http.MethodPost: true,
http.MethodPut: true,
http.MethodPatch: true,
http.MethodDelete: true,
}

// checkCSRF enforces the double-submit CSRF pattern on mutating requests.
// The SPA reads the non-HttpOnly osctrl_csrf cookie and echoes it via the
// X-CSRF-Token header on every mutation; we constant-time-compare:
// 1. header == cookie value (classic double-submit), AND
// 2. cookie value == AdminUser.CSRFToken (defeats a cookie-tossing
// attacker who can set both header and cookie without DB write access).
//
// CLI clients that authenticate purely via Authorization: Bearer (no cookie)
// are exempt — there is no browser to ride a cross-site request from.
//
// Note: AdminUser.CSRFToken rotates on every successful /login (see
// LoginHandler ↦ Users.UpdateMetadata). Concurrent logins of the same user
// race; the loser keeps a cookie that no longer matches the stored value
// and gets 403 on the next mutation. APIToken refresh / clear also clear
// CSRFToken (see pkg/users.UpdateToken / ClearToken) so a stale CSRF
// cookie cannot outlive its session.
func checkCSRF(r *http.Request, username string) bool {
// r.Cookie returns ErrNoCookie only when the cookie name is absent;
// an empty-value cookie returns (cookie, nil). Treating the empty case
// as "Bearer client" would bypass CSRF — instead, the call to
// extractHeaderToken upstream rejects empty-value cookies before we
// reach this function (the trimmed value falls through to "" return).
if _, err := r.Cookie(cookieNameToken); err != nil {
// No session cookie ⇒ Bearer-only client (CLI/CI). Nothing to CSRF.
return true
}
headerToken := strings.TrimSpace(r.Header.Get(headerNameCSRF))
cookie, err := r.Cookie(cookieNameCSRF)
if err != nil || headerToken == "" {
return false
}
cookieValue := strings.TrimSpace(cookie.Value)
if subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookieValue)) != 1 {
return false
}
user, err := apiUsers.Get(username)
if err != nil || user.CSRFToken == "" {
return false
}
return strings.TrimSpace(splitToken[1])
return subtle.ConstantTimeCompare([]byte(cookieValue), []byte(user.CSRFToken)) == 1
}

// Handler to check access to a resource based on the authentication enabled
Expand All @@ -41,12 +108,51 @@ func handlerAuthCheck(h http.Handler, auth, jwtSecret string) http.Handler {
// Set middleware values
token := extractHeaderToken(r)
if token == "" {
http.Redirect(w, r, forbiddenPath, http.StatusForbidden)
if utils.AcceptsJSON(r) {
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusUnauthorized,
types.ApiErrorResponse{Error: "unauthorized", Code: "unauthorized"})
return
}
// 302 is required by http.Redirect; the legacy 403 didn't actually trigger
// a redirect in any browser since http.Redirect demands a 3xx status.
http.Redirect(w, r, forbiddenPath, http.StatusFound)
return
}
claims, valid := apiUsers.CheckToken(jwtSecret, token)
if !valid {
http.Redirect(w, r, forbiddenPath, http.StatusForbidden)
if utils.AcceptsJSON(r) {
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusUnauthorized,
types.ApiErrorResponse{Error: "unauthorized", Code: "unauthorized"})
return
}
// 302 is required by http.Redirect; the legacy 403 didn't actually trigger
// a redirect in any browser since http.Redirect demands a 3xx status.
http.Redirect(w, r, forbiddenPath, http.StatusFound)
return
}
// Match the presented token against the user's currently-stored APIToken
// so that refresh/delete on /users/{username}/token invalidates old JWTs.
// (CheckToken above only validates the signature.) Service users with no
// stored token are rejected immediately. Constant-time comparison guards
// against timing-side-channel leaks of the stored token.
user, uerr := apiUsers.Get(claims.Username)
tokenMatches := uerr == nil && user.APIToken != "" &&
subtle.ConstantTimeCompare([]byte(user.APIToken), []byte(token)) == 1
if !tokenMatches {
if utils.AcceptsJSON(r) {
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusUnauthorized,
types.ApiErrorResponse{Error: "unauthorized", Code: "unauthorized"})
return
}
http.Redirect(w, r, forbiddenPath, http.StatusFound)
return
}
// CSRF guard for cookie-authenticated mutating requests. CLI Bearer
// clients are exempt via the cookieNameToken probe inside checkCSRF.
//
if mutatingMethods[r.Method] && !checkCSRF(r, claims.Username) {
utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusForbidden,
types.ApiErrorResponse{Error: "csrf token missing or invalid", Code: "csrf"})
return
}
// Update metadata for the user
Expand Down
Loading