detached 705579a

Merge pull request #52 from bnema/fix/security-audit-and-localhost-registry

fix(security): UTC timestamps and localhost registry image rewriting
commit
705579a62f83068132d6d9a5504eb8d2ebbee4b5
author
bnema · · 2026-02-03 20:49 UTC
committer
GitHub · · 2026-02-03 20:49 UTC
merge of
9e3958c + 2b4e30b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.10.2
5 files · +200 -12
M internal/adapters/in/http/auth/handler.go +3 -3
@@ -147,7 +147,7 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
147147 response := PasswordResponse{
148148 Token: token,
149149 ExpiresIn: int(expiry.Seconds()),
150- IssuedAt: time.Now().Format(time.RFC3339),
150+ IssuedAt: time.Now().UTC().Format(time.RFC3339),
151151 }
152152
153153 w.Header().Set("Content-Type", "application/json")
@@ -244,7 +244,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
244244 response := dto.TokenResponse{
245245 Token: accessToken,
246246 ExpiresIn: 300, // 5 minutes in seconds
247- IssuedAt: time.Now().Format(time.RFC3339),
247+ IssuedAt: time.Now().UTC().Format(time.RFC3339),
248248 }
249249
250250 w.Header().Set("Content-Type", "application/json")
@@ -266,7 +266,7 @@ func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger)
266266 response := dto.TokenResponse{
267267 Token: "", // Empty token indicates limited access
268268 ExpiresIn: 60, // 1 minute
269- IssuedAt: time.Now().Format(time.RFC3339),
269+ IssuedAt: time.Now().UTC().Format(time.RFC3339),
270270 }
271271
272272 w.Header().Set("Content-Type", "application/json")
@@ -147,7 +147,7 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
147 response := PasswordResponse{147 response := PasswordResponse{
148 Token: token,148 Token: token,
149 ExpiresIn: int(expiry.Seconds()),149 ExpiresIn: int(expiry.Seconds()),
150- IssuedAt: time.Now().Format(time.RFC3339),150+ IssuedAt: time.Now().UTC().Format(time.RFC3339),
151 }151 }
152 152
153 w.Header().Set("Content-Type", "application/json")153 w.Header().Set("Content-Type", "application/json")
@@ -244,7 +244,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
244 response := dto.TokenResponse{244 response := dto.TokenResponse{
245 Token: accessToken,245 Token: accessToken,
246 ExpiresIn: 300, // 5 minutes in seconds246 ExpiresIn: 300, // 5 minutes in seconds
247- IssuedAt: time.Now().Format(time.RFC3339),247+ IssuedAt: time.Now().UTC().Format(time.RFC3339),
248 }248 }
249 249
250 w.Header().Set("Content-Type", "application/json")250 w.Header().Set("Content-Type", "application/json")
@@ -266,7 +266,7 @@ func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger)
266 response := dto.TokenResponse{266 response := dto.TokenResponse{
267 Token: "", // Empty token indicates limited access267 Token: "", // Empty token indicates limited access
268 ExpiresIn: 60, // 1 minute268 ExpiresIn: 60, // 1 minute
269- IssuedAt: time.Now().Format(time.RFC3339),269+ IssuedAt: time.Now().UTC().Format(time.RFC3339),
270 }270 }
271 271
272 w.Header().Set("Content-Type", "application/json")272 w.Header().Set("Content-Type", "application/json")
M internal/domain/auth.go +1 -1
@@ -43,7 +43,7 @@ func (t *Token) IsExpired() bool {
4343 if t.ExpiresAt.IsZero() {
4444 return false // Never expires
4545 }
46- return time.Now().After(t.ExpiresAt)
46+ return time.Now().UTC().After(t.ExpiresAt)
4747 }
4848
4949 // SecretsBackend represents the type of secrets storage backend.
@@ -43,7 +43,7 @@ func (t *Token) IsExpired() bool {
43 if t.ExpiresAt.IsZero() {43 if t.ExpiresAt.IsZero() {
44 return false // Never expires44 return false // Never expires
45 }45 }
46- return time.Now().After(t.ExpiresAt)46+ return time.Now().UTC().After(t.ExpiresAt)
47 }47 }
48 48
49 // SecretsBackend represents the type of secrets storage backend.49 // SecretsBackend represents the type of secrets storage backend.
M internal/usecase/auth/service.go +4 -4
@@ -231,7 +231,7 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger)
231231 return nil
232232 }
233233
234- if time.Now().Unix() > tokenClaims.ExpiresAt {
234+ if time.Now().UTC().Unix() > tokenClaims.ExpiresAt {
235235 log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
236236 return domain.ErrExpiredToken
237237 }
@@ -256,7 +256,7 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
256256
257257 // Security: ensure token was recently issued to prevent attackers with
258258 // stolen secrets from creating arbitrary short-lived tokens
259- now := time.Now().Unix()
259+ now := time.Now().UTC().Unix()
260260 tokenAge := now - claims.IssuedAt
261261 if tokenAge > maxAccessTokenLifetimeSecs {
262262 return false // Old tokens require store validation
@@ -275,7 +275,7 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
275275 log := zerowrap.FromCtx(ctx)
276276
277277 tokenID := uuid.New().String()
278- now := time.Now()
278+ now := time.Now().UTC()
279279
280280 // Build token
281281 token := &domain.Token{
@@ -345,7 +345,7 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope
345345 }
346346
347347 tokenID := uuid.New().String()
348- now := time.Now()
348+ now := time.Now().UTC()
349349
350350 claims := jwt.MapClaims{
351351 "jti": tokenID,
@@ -231,7 +231,7 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger)
231 return nil231 return nil
232 }232 }
233 233
234- if time.Now().Unix() > tokenClaims.ExpiresAt {234+ if time.Now().UTC().Unix() > tokenClaims.ExpiresAt {
235 log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")235 log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
236 return domain.ErrExpiredToken236 return domain.ErrExpiredToken
237 }237 }
@@ -256,7 +256,7 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
256 256
257 // Security: ensure token was recently issued to prevent attackers with257 // Security: ensure token was recently issued to prevent attackers with
258 // stolen secrets from creating arbitrary short-lived tokens258 // stolen secrets from creating arbitrary short-lived tokens
259- now := time.Now().Unix()259+ now := time.Now().UTC().Unix()
260 tokenAge := now - claims.IssuedAt260 tokenAge := now - claims.IssuedAt
261 if tokenAge > maxAccessTokenLifetimeSecs {261 if tokenAge > maxAccessTokenLifetimeSecs {
262 return false // Old tokens require store validation262 return false // Old tokens require store validation
@@ -275,7 +275,7 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
275 log := zerowrap.FromCtx(ctx)275 log := zerowrap.FromCtx(ctx)
276 276
277 tokenID := uuid.New().String()277 tokenID := uuid.New().String()
278- now := time.Now()278+ now := time.Now().UTC()
279 279
280 // Build token280 // Build token
281 token := &domain.Token{281 token := &domain.Token{
@@ -345,7 +345,7 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope
345 }345 }
346 346
347 tokenID := uuid.New().String()347 tokenID := uuid.New().String()
348- now := time.Now()348+ now := time.Now().UTC()
349 349
350 claims := jwt.MapClaims{350 claims := jwt.MapClaims{
351 "jti": tokenID,351 "jti": tokenID,
M internal/usecase/container/service.go +70 -4
@@ -636,14 +636,80 @@ func (s *Service) buildImageRef(image string) string {
636636 return image
637637 }
638638
639- repoPart := strings.Split(image, ":")[0]
640- if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") {
639+ // Normalize registry domain by trimming any trailing slash
640+ reg := strings.TrimSuffix(s.config.RegistryDomain, "/")
641+
642+ // Check if image already has the registry domain prefix
643+ prefix := reg + "/"
644+ if strings.HasPrefix(image, prefix) {
641645 return image
642646 }
643- if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
647+
648+ // Check if image already has an explicit registry (host:port/ or host.domain/).
649+ // We need to detect patterns like:
650+ // - "docker.io/library/nginx:latest" (has dot and slash)
651+ // - "localhost:5001/myapp:latest" (has host:port/ pattern)
652+ // - "myregistry:5000/app:v1" (has host:port/ pattern)
653+ // - "[fd00::1]:5000/app:v1" (IPv6 with port)
654+ if hasExplicitRegistry(image) {
644655 return image
645656 }
646- return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
657+
658+ return fmt.Sprintf("%s/%s", reg, image)
659+}
660+
661+// hasExplicitRegistry checks if an image reference already includes an explicit registry.
662+// Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image",
663+// "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image".
664+func hasExplicitRegistry(image string) bool {
665+ // Find the first slash which separates registry from image name
666+ slashIdx := strings.Index(image, "/")
667+ if slashIdx == -1 {
668+ return false // No slash means no registry prefix (e.g., "myapp:latest")
669+ }
670+
671+ // Extract the part before the first slash (potential registry)
672+ registryPart := image[:slashIdx]
673+
674+ // Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000")
675+ if strings.HasPrefix(registryPart, "[") {
676+ // Look for closing bracket
677+ if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 {
678+ // Valid bracketed IPv6: either ends at ] or has ]:port
679+ return true
680+ }
681+ }
682+
683+ // Check for localhost (with or without port)
684+ if registryPart == "localhost" {
685+ return true
686+ }
687+
688+ // Check for host:port pattern (e.g., "localhost:5000", "registry:8080")
689+ if colonIdx := strings.LastIndex(registryPart, ":"); colonIdx != -1 {
690+ port := registryPart[colonIdx+1:]
691+ // If everything after colon is digits, it's a port
692+ if len(port) > 0 && isNumeric(port) {
693+ return true
694+ }
695+ }
696+
697+ // Check for domain pattern (has a dot, e.g., "docker.io", "gcr.io")
698+ if strings.Contains(registryPart, ".") {
699+ return true
700+ }
701+
702+ return false
703+}
704+
705+// isNumeric checks if a string contains only digits.
706+func isNumeric(s string) bool {
707+ for _, c := range s {
708+ if c < '0' || c > '9' {
709+ return false
710+ }
711+ }
712+ return true
647713 }
648714
649715 func normalizePullPolicy(policy string) string {
@@ -636,14 +636,80 @@ func (s *Service) buildImageRef(image string) string {
636 return image636 return image
637 }637 }
638 638
639- repoPart := strings.Split(image, ":")[0]639+ // Normalize registry domain by trimming any trailing slash
640- if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") {640+ reg := strings.TrimSuffix(s.config.RegistryDomain, "/")
641+
642+ // Check if image already has the registry domain prefix
643+ prefix := reg + "/"
644+ if strings.HasPrefix(image, prefix) {
641 return image645 return image
642 }646 }
643- if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {647+
648+ // Check if image already has an explicit registry (host:port/ or host.domain/).
649+ // We need to detect patterns like:
650+ // - "docker.io/library/nginx:latest" (has dot and slash)
651+ // - "localhost:5001/myapp:latest" (has host:port/ pattern)
652+ // - "myregistry:5000/app:v1" (has host:port/ pattern)
653+ // - "[fd00::1]:5000/app:v1" (IPv6 with port)
654+ if hasExplicitRegistry(image) {
644 return image655 return image
645 }656 }
646- return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)657+
658+ return fmt.Sprintf("%s/%s", reg, image)
659+}
660+
661+// hasExplicitRegistry checks if an image reference already includes an explicit registry.
662+// Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image",
663+// "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image".
664+func hasExplicitRegistry(image string) bool {
665+ // Find the first slash which separates registry from image name
666+ slashIdx := strings.Index(image, "/")
667+ if slashIdx == -1 {
668+ return false // No slash means no registry prefix (e.g., "myapp:latest")
669+ }
670+
671+ // Extract the part before the first slash (potential registry)
672+ registryPart := image[:slashIdx]
673+
674+ // Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000")
675+ if strings.HasPrefix(registryPart, "[") {
676+ // Look for closing bracket
677+ if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 {
678+ // Valid bracketed IPv6: either ends at ] or has ]:port
679+ return true
680+ }
681+ }
682+
683+ // Check for localhost (with or without port)
684+ if registryPart == "localhost" {
685+ return true
686+ }
687+
688+ // Check for host:port pattern (e.g., "localhost:5000", "registry:8080")
689+ if colonIdx := strings.LastIndex(registryPart, ":"); colonIdx != -1 {
690+ port := registryPart[colonIdx+1:]
691+ // If everything after colon is digits, it's a port
692+ if len(port) > 0 && isNumeric(port) {
693+ return true
694+ }
695+ }
696+
697+ // Check for domain pattern (has a dot, e.g., "docker.io", "gcr.io")
698+ if strings.Contains(registryPart, ".") {
699+ return true
700+ }
701+
702+ return false
703+}
704+
705+// isNumeric checks if a string contains only digits.
706+func isNumeric(s string) bool {
707+ for _, c := range s {
708+ if c < '0' || c > '9' {
709+ return false
710+ }
711+ }
712+ return true
647 }713 }
648 714
649 func normalizePullPolicy(policy string) string {715 func normalizePullPolicy(policy string) string {
M internal/usecase/container/service_test.go +122 -0
@@ -743,6 +743,128 @@ func TestMergeEnvironmentVariables(t *testing.T) {
743743 assert.Contains(t, result, "BAZ=user")
744744 }
745745
746+func TestBuildImageRef(t *testing.T) {
747+ tests := []struct {
748+ name string
749+ image string
750+ registryAuthEnabled bool
751+ registryDomain string
752+ wantRef string
753+ }{
754+ {
755+ name: "adds registry domain prefix",
756+ image: "myapp:latest",
757+ registryAuthEnabled: true,
758+ registryDomain: "registry.example.com",
759+ wantRef: "registry.example.com/myapp:latest",
760+ },
761+ {
762+ name: "keeps existing registry domain prefix",
763+ image: "registry.example.com/myapp:latest",
764+ registryAuthEnabled: true,
765+ registryDomain: "registry.example.com",
766+ wantRef: "registry.example.com/myapp:latest",
767+ },
768+ {
769+ name: "skips external registry images",
770+ image: "docker.io/library/nginx:latest",
771+ registryAuthEnabled: true,
772+ registryDomain: "registry.example.com",
773+ wantRef: "docker.io/library/nginx:latest",
774+ },
775+ {
776+ name: "returns original when auth disabled",
777+ image: "myapp:latest",
778+ registryAuthEnabled: false,
779+ registryDomain: "registry.example.com",
780+ wantRef: "myapp:latest",
781+ },
782+ {
783+ name: "localhost domain adds prefix",
784+ image: "myapp:latest",
785+ registryAuthEnabled: true,
786+ registryDomain: "localhost:5000",
787+ wantRef: "localhost:5000/myapp:latest",
788+ },
789+ {
790+ name: "localhost domain keeps existing prefix",
791+ image: "localhost:5000/myapp:latest",
792+ registryAuthEnabled: true,
793+ registryDomain: "localhost:5000",
794+ wantRef: "localhost:5000/myapp:latest",
795+ },
796+ {
797+ name: "explicit host:port different than RegistryDomain",
798+ image: "localhost:5001/myapp:latest",
799+ registryAuthEnabled: true,
800+ registryDomain: "localhost:5000",
801+ wantRef: "localhost:5001/myapp:latest",
802+ },
803+ {
804+ name: "ghcr.io external registry",
805+ image: "ghcr.io/owner/repo:latest",
806+ registryAuthEnabled: true,
807+ registryDomain: "localhost:5000",
808+ wantRef: "ghcr.io/owner/repo:latest",
809+ },
810+ {
811+ name: "gcr.io external registry",
812+ image: "gcr.io/project/image:v1",
813+ registryAuthEnabled: true,
814+ registryDomain: "registry.example.com",
815+ wantRef: "gcr.io/project/image:v1",
816+ },
817+ {
818+ name: "quay.io external registry",
819+ image: "quay.io/org/app:tag",
820+ registryAuthEnabled: true,
821+ registryDomain: "localhost:5000",
822+ wantRef: "quay.io/org/app:tag",
823+ },
824+ {
825+ name: "localhost without port keeps existing prefix",
826+ image: "localhost/myapp:latest",
827+ registryAuthEnabled: true,
828+ registryDomain: "localhost:5000",
829+ wantRef: "localhost/myapp:latest",
830+ },
831+ {
832+ name: "registry domain with trailing slash",
833+ image: "myapp:latest",
834+ registryAuthEnabled: true,
835+ registryDomain: "registry.example.com/",
836+ wantRef: "registry.example.com/myapp:latest",
837+ },
838+ {
839+ name: "ipv6 registry without port",
840+ image: "[fd00::1]/myapp:latest",
841+ registryAuthEnabled: true,
842+ registryDomain: "localhost:5000",
843+ wantRef: "[fd00::1]/myapp:latest",
844+ },
845+ {
846+ name: "ipv6 registry with port",
847+ image: "[fd00::1]:5000/myapp:latest",
848+ registryAuthEnabled: true,
849+ registryDomain: "localhost:5000",
850+ wantRef: "[fd00::1]:5000/myapp:latest",
851+ },
852+ }
853+
854+ for _, tt := range tests {
855+ t.Run(tt.name, func(t *testing.T) {
856+ svc := &Service{
857+ config: Config{
858+ RegistryAuthEnabled: tt.registryAuthEnabled,
859+ RegistryDomain: tt.registryDomain,
860+ },
861+ }
862+ gotRef := svc.buildImageRef(tt.image)
863+ assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference")
864+ })
865+ }
866+}
867+
746868 func TestRewriteToRegistryDomain(t *testing.T) {
747869 tests := []struct {
748870 name string
@@ -743,6 +743,128 @@ func TestMergeEnvironmentVariables(t *testing.T) {
743 assert.Contains(t, result, "BAZ=user")743 assert.Contains(t, result, "BAZ=user")
744 }744 }
745 745
746+func TestBuildImageRef(t *testing.T) {
747+ tests := []struct {
748+ name string
749+ image string
750+ registryAuthEnabled bool
751+ registryDomain string
752+ wantRef string
753+ }{
754+ {
755+ name: "adds registry domain prefix",
756+ image: "myapp:latest",
757+ registryAuthEnabled: true,
758+ registryDomain: "registry.example.com",
759+ wantRef: "registry.example.com/myapp:latest",
760+ },
761+ {
762+ name: "keeps existing registry domain prefix",
763+ image: "registry.example.com/myapp:latest",
764+ registryAuthEnabled: true,
765+ registryDomain: "registry.example.com",
766+ wantRef: "registry.example.com/myapp:latest",
767+ },
768+ {
769+ name: "skips external registry images",
770+ image: "docker.io/library/nginx:latest",
771+ registryAuthEnabled: true,
772+ registryDomain: "registry.example.com",
773+ wantRef: "docker.io/library/nginx:latest",
774+ },
775+ {
776+ name: "returns original when auth disabled",
777+ image: "myapp:latest",
778+ registryAuthEnabled: false,
779+ registryDomain: "registry.example.com",
780+ wantRef: "myapp:latest",
781+ },
782+ {
783+ name: "localhost domain adds prefix",
784+ image: "myapp:latest",
785+ registryAuthEnabled: true,
786+ registryDomain: "localhost:5000",
787+ wantRef: "localhost:5000/myapp:latest",
788+ },
789+ {
790+ name: "localhost domain keeps existing prefix",
791+ image: "localhost:5000/myapp:latest",
792+ registryAuthEnabled: true,
793+ registryDomain: "localhost:5000",
794+ wantRef: "localhost:5000/myapp:latest",
795+ },
796+ {
797+ name: "explicit host:port different than RegistryDomain",
798+ image: "localhost:5001/myapp:latest",
799+ registryAuthEnabled: true,
800+ registryDomain: "localhost:5000",
801+ wantRef: "localhost:5001/myapp:latest",
802+ },
803+ {
804+ name: "ghcr.io external registry",
805+ image: "ghcr.io/owner/repo:latest",
806+ registryAuthEnabled: true,
807+ registryDomain: "localhost:5000",
808+ wantRef: "ghcr.io/owner/repo:latest",
809+ },
810+ {
811+ name: "gcr.io external registry",
812+ image: "gcr.io/project/image:v1",
813+ registryAuthEnabled: true,
814+ registryDomain: "registry.example.com",
815+ wantRef: "gcr.io/project/image:v1",
816+ },
817+ {
818+ name: "quay.io external registry",
819+ image: "quay.io/org/app:tag",
820+ registryAuthEnabled: true,
821+ registryDomain: "localhost:5000",
822+ wantRef: "quay.io/org/app:tag",
823+ },
824+ {
825+ name: "localhost without port keeps existing prefix",
826+ image: "localhost/myapp:latest",
827+ registryAuthEnabled: true,
828+ registryDomain: "localhost:5000",
829+ wantRef: "localhost/myapp:latest",
830+ },
831+ {
832+ name: "registry domain with trailing slash",
833+ image: "myapp:latest",
834+ registryAuthEnabled: true,
835+ registryDomain: "registry.example.com/",
836+ wantRef: "registry.example.com/myapp:latest",
837+ },
838+ {
839+ name: "ipv6 registry without port",
840+ image: "[fd00::1]/myapp:latest",
841+ registryAuthEnabled: true,
842+ registryDomain: "localhost:5000",
843+ wantRef: "[fd00::1]/myapp:latest",
844+ },
845+ {
846+ name: "ipv6 registry with port",
847+ image: "[fd00::1]:5000/myapp:latest",
848+ registryAuthEnabled: true,
849+ registryDomain: "localhost:5000",
850+ wantRef: "[fd00::1]:5000/myapp:latest",
851+ },
852+ }
853+
854+ for _, tt := range tests {
855+ t.Run(tt.name, func(t *testing.T) {
856+ svc := &Service{
857+ config: Config{
858+ RegistryAuthEnabled: tt.registryAuthEnabled,
859+ RegistryDomain: tt.registryDomain,
860+ },
861+ }
862+ gotRef := svc.buildImageRef(tt.image)
863+ assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference")
864+ })
865+ }
866+}
867+
746 func TestRewriteToRegistryDomain(t *testing.T) {868 func TestRewriteToRegistryDomain(t *testing.T) {
747 tests := []struct {869 tests := []struct {
748 name string870 name string

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close