From 8e5aa1443bf57e784df6d3923f14ea7b43465bce Mon Sep 17 00:00:00 2001 From: bsaurusrex <82356519+bsaurusrex@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:01:54 +0800 Subject: [PATCH 1/3] feat: add a protected /api/version endpoint Adds GET /api/version returning the running Tinyauth version so an external update checker or monitoring tool can read it. The endpoint requires an authenticated user context and is deliberately kept out of contextSkipPathsPrefix, so the version is never exposed to anonymous callers (an unauthenticated request gets the standard 401 body). Closes #1160 Co-Authored-By: Claude Opus 5.5 --- internal/controller/context_controller.go | 41 ++++++++++++ .../controller/context_controller_test.go | 62 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index 780edd04..2d9c3a64 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -75,6 +75,14 @@ type AppContextResponse struct { App ACRApp `json:"app"` } +// VR -> Version Response + +type VersionResponse struct { + Status int `json:"status"` + Message string `json:"message"` + Version string `json:"version"` +} + type ContextControllerInput struct { dig.In @@ -105,6 +113,10 @@ func NewContextController(i ContextControllerInput) *ContextController { contextGroup.GET("/user", controller.userContextHandler) contextGroup.GET("/app", controller.appContextHandler) + // Protected: the version is only returned to an authenticated user, so it is not exposed publicly. + // It is intentionally kept out of contextSkipPathsPrefix so the context middleware still runs. + i.RouterGroup.GET("/version", controller.versionHandler) + return controller } @@ -148,6 +160,35 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { c.JSON(200, userContext) } +func (controller *ContextController) versionHandler(c *gin.Context) { + context, err := new(model.UserContext).NewFromGin(c) + + if err != nil { + if !errors.Is(err, model.ErrUserContextNotFound) { + controller.log.App.Error().Err(err).Msg("Failed to create user context from request") + } + c.JSON(200, VersionResponse{ + Status: 401, + Message: "Unauthorized", + }) + return + } + + if !context.IsAuthenticated() { + c.JSON(200, VersionResponse{ + Status: 401, + Message: "Unauthorized", + }) + return + } + + c.JSON(200, VersionResponse{ + Status: 200, + Message: "Success", + Version: model.Version, + }) +} + func (controller *ContextController) appContextHandler(c *gin.Context) { c.JSON(200, AppContextResponse{ Status: 200, diff --git a/internal/controller/context_controller_test.go b/internal/controller/context_controller_test.go index c188adec..eb113a30 100644 --- a/internal/controller/context_controller_test.go +++ b/internal/controller/context_controller_test.go @@ -108,6 +108,68 @@ func TestContextController(t *testing.T) { return string(bytes) }(), }, + { + description: "Ensure version returns 401 when unauthorized", + middlewares: []gin.HandlerFunc{}, + path: "/api/version", + expected: func() string { + expectedVersionResponse := VersionResponse{ + Status: 401, + Message: "Unauthorized", + } + bytes, err := json.Marshal(expectedVersionResponse) + require.NoError(t, err) + return string(bytes) + }(), + }, + { + description: "Ensure version returns 401 when context is unauthenticated", + middlewares: []gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &model.UserContext{ + Authenticated: false, + Provider: model.ProviderTailscale, + }) + }, + }, + path: "/api/version", + expected: func() string { + expectedVersionResponse := VersionResponse{ + Status: 401, + Message: "Unauthorized", + } + bytes, err := json.Marshal(expectedVersionResponse) + require.NoError(t, err) + return string(bytes) + }(), + }, + { + description: "Ensure version returns the build version when authorized", + middlewares: []gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &model.UserContext{ + Authenticated: true, + Provider: model.ProviderLocal, + Local: &model.LocalContext{ + BaseContext: model.BaseContext{ + Username: "johndoe", + }, + }, + }) + }, + }, + path: "/api/version", + expected: func() string { + expectedVersionResponse := VersionResponse{ + Status: 200, + Message: "Success", + Version: model.Version, + } + bytes, err := json.Marshal(expectedVersionResponse) + require.NoError(t, err) + return string(bytes) + }(), + }, } for _, test := range tests { From 07d9be54083a6d09c8a6d12589eba15440087b8f Mon Sep 17 00:00:00 2001 From: bsaurusrex <82356519+bsaurusrex@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:26:15 +0800 Subject: [PATCH 2/3] docs: document the protected behavior of versionHandler Co-Authored-By: Claude Opus 5.5 --- internal/controller/context_controller.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index 2d9c3a64..b57dc65e 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -160,6 +160,10 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { c.JSON(200, userContext) } +// versionHandler returns the running Tinyauth version, but only to an authenticated user. An +// unauthenticated request (no context, or a present-but-unauthenticated one such as a pending TOTP +// or a Tailscale probe) receives the standard 401 body with no version, so the version is never +// disclosed publicly and cannot be used to fingerprint the deployment for known vulnerabilities. func (controller *ContextController) versionHandler(c *gin.Context) { context, err := new(model.UserContext).NewFromGin(c) From 564b47ebf31769ab2a5e3e15472f2edd57b1126c Mon Sep 17 00:00:00 2001 From: bsaurusrex <82356519+bsaurusrex@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:22:31 +0800 Subject: [PATCH 3/3] feat: include commit hash and build timestamp in /api/version Return model.CommitHash and model.BuildTimestamp alongside the version, and drop the redundant comments around the version route and handler. Co-Authored-By: Claude Opus 5.5 --- internal/controller/context_controller.go | 22 +++++++++---------- .../controller/context_controller_test.go | 10 +++++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index b57dc65e..b0cbf1d7 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -78,9 +78,11 @@ type AppContextResponse struct { // VR -> Version Response type VersionResponse struct { - Status int `json:"status"` - Message string `json:"message"` - Version string `json:"version"` + Status int `json:"status"` + Message string `json:"message"` + Version string `json:"version"` + CommitHash string `json:"commitHash"` + BuildTimestamp string `json:"buildTimestamp"` } type ContextControllerInput struct { @@ -113,8 +115,6 @@ func NewContextController(i ContextControllerInput) *ContextController { contextGroup.GET("/user", controller.userContextHandler) contextGroup.GET("/app", controller.appContextHandler) - // Protected: the version is only returned to an authenticated user, so it is not exposed publicly. - // It is intentionally kept out of contextSkipPathsPrefix so the context middleware still runs. i.RouterGroup.GET("/version", controller.versionHandler) return controller @@ -160,10 +160,6 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { c.JSON(200, userContext) } -// versionHandler returns the running Tinyauth version, but only to an authenticated user. An -// unauthenticated request (no context, or a present-but-unauthenticated one such as a pending TOTP -// or a Tailscale probe) receives the standard 401 body with no version, so the version is never -// disclosed publicly and cannot be used to fingerprint the deployment for known vulnerabilities. func (controller *ContextController) versionHandler(c *gin.Context) { context, err := new(model.UserContext).NewFromGin(c) @@ -187,9 +183,11 @@ func (controller *ContextController) versionHandler(c *gin.Context) { } c.JSON(200, VersionResponse{ - Status: 200, - Message: "Success", - Version: model.Version, + Status: 200, + Message: "Success", + Version: model.Version, + CommitHash: model.CommitHash, + BuildTimestamp: model.BuildTimestamp, }) } diff --git a/internal/controller/context_controller_test.go b/internal/controller/context_controller_test.go index eb113a30..e54b2d56 100644 --- a/internal/controller/context_controller_test.go +++ b/internal/controller/context_controller_test.go @@ -144,7 +144,7 @@ func TestContextController(t *testing.T) { }(), }, { - description: "Ensure version returns the build version when authorized", + description: "Ensure version returns the build info when authorized", middlewares: []gin.HandlerFunc{ func(c *gin.Context) { c.Set("context", &model.UserContext{ @@ -161,9 +161,11 @@ func TestContextController(t *testing.T) { path: "/api/version", expected: func() string { expectedVersionResponse := VersionResponse{ - Status: 200, - Message: "Success", - Version: model.Version, + Status: 200, + Message: "Success", + Version: model.Version, + CommitHash: model.CommitHash, + BuildTimestamp: model.BuildTimestamp, } bytes, err := json.Marshal(expectedVersionResponse) require.NoError(t, err)