Compare commits

..

1 Commits

Author SHA1 Message Date
Phil Calder
c60755fbc7 Update community branch (#13208)
Co-authored-by: Hannah Cooper <hannah.cooper@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: Steven Kang <skan070@gmail.com>
Co-authored-by: Josiah Clumont <josiah.clumont@portainer.io>
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Co-authored-by: ferreiraborgesaxel-design <ferreiraborgesaxel-design@users.noreply.github.com>
2026-06-09 16:11:47 +12:00
348 changed files with 10109 additions and 10307 deletions

View File

@@ -3,13 +3,13 @@ body:
attributes:
value: |
# Welcome!
Thanks for suggesting an idea for Portainer!
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion category](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
**DO NOT FILE DUPLICATE REQUESTS.**
- type: textarea

View File

@@ -1,86 +0,0 @@
name: Build image
on:
push:
branches: [develop]
tags: ['v*']
workflow_dispatch: {}
env:
IMAGE: ghcr.io/vvzvlad/portainer-ce
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve version
id: ver
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Install client dependencies
# CI forces pnpm into --frozen-lockfile, which fails with
# ERR_PNPM_LOCKFILE_CONFIG_MISMATCH because the committed lockfile lacks
# the pnpmfileChecksum for the configDependencies in package.json.
# Reconcile the lockfile explicitly; the later frozen install in
# `make client-deps` then finds a matching lockfile. pnpm ignores the
# npm_config_frozen_lockfile env var, so an explicit flag is required.
run: pnpm install --no-frozen-lockfile
- name: Build client and server
env:
SKIP_GO_GET: "true"
CONTAINER_IMAGE_TAG: ${{ steps.ver.outputs.version }}
BUILDNUMBER: ${{ github.run_number }}
# Pin the embedded commit to the full SHA so it matches the image
# GIT_COMMIT build-arg and does not depend on the shallow checkout.
GIT_COMMIT_HASH: ${{ github.sha }}
# ENV=production selects webpack/webpack.production.js (minified bundle),
# matching the official CE image; the Makefile default is development.
run: make build-all ENV=production
- name: Ensure storybook directory exists
# make build-all does not produce dist/storybook, but alpine.Dockerfile
# has `COPY dist/storybook* /storybook/`; without a match the docker build fails.
run: mkdir -p dist/storybook
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image (linux/amd64, alpine base)
uses: docker/build-push-action@v6
with:
context: .
file: build/linux/alpine.Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE }}:${{ steps.ver.outputs.version }}
${{ env.IMAGE }}:latest
build-args: |
GIT_COMMIT=${{ github.sha }}

View File

@@ -1,4 +1,4 @@
version: '2'
version: "2"
linters:
default: none
enable:

View File

@@ -1,4 +1,4 @@
version: '2'
version: "2"
run:
allow-parallel-runners: true
@@ -32,7 +32,7 @@ linters:
- exptostd
settings:
staticcheck:
checks: ['all', '-ST1003', '-ST1005', '-ST1016', '-SA1019', '-QF1003']
checks: ["all", "-ST1003", "-ST1005", "-ST1016", "-SA1019", "-QF1003"]
depguard:
rules:
main:
@@ -83,7 +83,7 @@ linters:
- ruleguard
settings:
ruleguard:
rules: './analysis/ssrf.go,./analysis/git.go'
rules: "./analysis/ssrf.go"
forbidigo:
forbid:
- pattern: ^tls\.Config$
@@ -91,11 +91,9 @@ linters:
- pattern: ^tls\.Config\.(InsecureSkipVerify|MinVersion|MaxVersion|CipherSuites|CurvePreferences)$
msg: Do not set this field directly, use crypto.CreateTLSConfiguration() instead
- pattern: ^object\.(Commit|Tag)\.Verify$
msg: 'Not allowed because of FIPS mode'
msg: "Not allowed because of FIPS mode"
- pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$
msg: 'Not allowed because of FIPS mode'
- pattern: ^git\.PlainClone(Context|WithOptions)?$
msg: Use git.CloneContext with NewNoSymlinkFS to prevent symlink traversal attacks
msg: "Not allowed because of FIPS mode"
analyze-types: true
exclusions:
generated: lax
@@ -108,9 +106,6 @@ linters:
linters:
- gocritic
text: ruleguard
- path: pkg/libhttp/ssrf/builder\.go
linters:
- forbidigo
paths:
- third_party$
- builtin$

View File

@@ -1,5 +1,3 @@
dist
api/datastore/test_data
coverage
pnpm-lock.yaml
coverage

View File

@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
Examples of behavior that contributes to creating a positive environment include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
@@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contribute@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at anthony.lapenna@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.

View File

@@ -6,7 +6,6 @@ TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.6
GOTESTSUM_VERSION?=v1.13.0
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
GOLANGCI_LINT_VERSION := $(shell cat $(shell git rev-parse --show-toplevel)/.golangci-version)
# Don't change anything below this line unless you know what you're doing
.DEFAULT_GOAL := help
@@ -91,25 +90,13 @@ format-server: ## Format server code
go fmt ./...
##@ Lint
.PHONY: lint lint-client lint-server check-lint-version
.PHONY: lint lint-client lint-server
lint: lint-client lint-server ## Lint all code
lint-client: ## Lint client code
pnpm run lint
check-lint-version:
@installed=v$$(golangci-lint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
if [ "$$installed" = "v" ]; then \
echo "ERROR: golangci-lint not found, need $(GOLANGCI_LINT_VERSION)"; \
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
exit 1; \
elif [ "$$installed" != "$(GOLANGCI_LINT_VERSION)" ]; then \
echo "ERROR: golangci-lint $$installed installed, need $(GOLANGCI_LINT_VERSION)"; \
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
exit 1; \
fi
lint-server: tidy check-lint-version ## Lint server code
lint-server: tidy ## Lint server code
golangci-lint run --timeout=10m -c .golangci.yaml
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml

View File

@@ -1,18 +0,0 @@
//go:build ignore
package gorules
import "github.com/quasilyte/go-ruleguard/dsl"
// inMemoryCloneWithWorktree flags git clone calls that use memory.NewStorage() as
// the storer while also writing files to a real worktree. This holds all git objects
// in heap for the duration of the clone, which is unbounded for user-supplied repos.
func inMemoryCloneWithWorktree(m dsl.Matcher) {
m.Match(`git.CloneContext($_, memory.NewStorage(), $wt, $_)`).
Where(m["wt"].Text != "nil").
Report(`git.CloneContext with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
m.Match(`git.Clone(memory.NewStorage(), $wt, $_)`).
Where(m["wt"].Text != "nil").
Report(`git.Clone with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
}

View File

@@ -4,72 +4,26 @@ package gorules
import "github.com/quasilyte/go-ruleguard/dsl"
// unwrappedHTTPTransport flags any bare http.Transport composite literal.
// All transports must be created via ssrf.NewTransport or ssrf.NewInternalTransport,
// which clone http.DefaultTransport and handle SSRF protection internally.
// unwrappedHTTPTransport flags http.Transport composite literals that are not
// the direct argument to ssrf.WrapTransport.
func unwrappedHTTPTransport(m dsl.Matcher) {
// Inline construction passed to a function call.
m.Match(`$f(&http.Transport{$*_})`).
Report(`$f receives a bare *http.Transport; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
Where(m["f"].Text != "ssrf.WrapTransport" && m["f"].Text != "WrapTransport" &&
m["f"].Text != "ssrf.WrapTransportInternal" && m["f"].Text != "WrapTransportInternal").
Report(`$f receives a bare *http.Transport; wrap with ssrf.WrapTransport() to enforce the SSRF protection policy`)
// Variable assigned a bare transport (cannot be tracked to a later WrapTransport call).
m.Match(`$_ := &http.Transport{$*_}`).
Report(`bare *http.Transport variable; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
m.Match(`$_.Transport = &http.Transport{$*_}`).
Report(`bare *http.Transport field assignment; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
Report(`bare *http.Transport variable; use ssrf.WrapTransport(&http.Transport{...}) inline instead`)
}
// helmGetterTransport flags getter.WithTransport calls that receive a bare *http.Transport.
// Helm v4 installs its own transport and bypasses http.DefaultTransport, so the transport
// passed here must be created via ssrf.NewTransport.
func helmGetterTransport(m dsl.Matcher) {
m.Match(`getter.WithTransport(&http.Transport{$*_})`).
Report(`getter.WithTransport called with a bare *http.Transport; use ssrf.NewTransport(tlsConfig) as Helm v4 bypasses http.DefaultTransport`)
}
// cloneDefaultTransport flags direct clones of *http.Transport outside main.go.
// The one legitimate clone is in main.go where http.DefaultTransport is globally
// wrapped with SSRF protection at server startup.
func cloneDefaultTransport(m dsl.Matcher) {
m.Match(`$_.(*http.Transport).Clone()`).
Where(!m.File().Name.Matches(`^main\.go$`)).
Report(`cloning *http.Transport directly is forbidden; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
}
// internalTransportMisuse flags calls to NewInternalTransport outside the proxy
// internalTransportMisuse flags calls to WrapTransportInternal outside the four proxy
// factory files where Chisel-tunnel and in-cluster K8s destinations are valid exemptions.
func internalTransportMisuse(m dsl.Matcher) {
m.Match(`ssrf.NewInternalTransport($*_)`).
m.Match(`ssrf.WrapTransportInternal($*_)`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker|agent|local_transport|edge_transport|docker_unix|docker_windows)\.go$`))).
Report(`NewInternalTransport bypasses SSRF validation; only valid in the proxy factory files for local sockets and internally-routed endpoints`)
}
// dialerOverride flags direct assignments to any of the dialer fields on a transport.
// The only valid assignments are in docker_unix.go and docker_windows.go where a
// custom dialer is required for unix sockets and named pipes.
func dialerOverride(m dsl.Matcher) {
m.Match(`$_.DialContext = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct DialContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.Match(`$_.Dial = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct Dial assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.Match(`$_.DialTLSContext = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct DialTLSContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.Match(`$_.DialTLS = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct DialTLS assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.File().Name.Matches(`^(docker|agent|local_transport|edge_transport)\.go$`))).
Report(`WrapTransportInternal bypasses SSRF validation; only valid in the kubernetes local/edge transport constructors and the docker/agent proxy factories`)
}

View File

@@ -1,7 +1,6 @@
package agent
import (
"context"
"crypto/tls"
"errors"
"fmt"
@@ -12,7 +11,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/url"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/rs/zerolog/log"
)
@@ -21,14 +19,10 @@ import (
//
// it sends a ping to the agent and parses the version and platform from the headers
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo
if err := ssrf.CheckURL(context.Background(), endpointUrl); err != nil {
return 0, "", err
}
httpCli := &http.Client{Timeout: 3 * time.Second}
if tlsConfig != nil {
httpCli.Transport = ssrf.NewTransport(tlsConfig)
httpCli.Transport = &http.Transport{TLSClientConfig: tlsConfig}
}
parsedURL, err := url.ParseURL(endpointUrl + "/ping")

View File

@@ -306,10 +306,6 @@ func (service *Service) snapshotAndLog(endpointID portainer.EndpointID, tunnelPo
Err(err).
Msg("unable to snapshot Edge environment")
if service.dataStore.IsErrObjectNotFound(err) {
service.close(endpointID)
}
return false
}

View File

@@ -187,24 +187,6 @@ func TestCheckTunnelsKeepsHasSnapshotFalseOnSnapshotFailure(t *testing.T) {
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot, "HasSnapshot must stay false after failure")
}
func TestCheckTunnelsClosesStaleEntryForDeletedEndpoint(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
// Endpoint is not created in the store, simulates deletion while tunnel stays open.
s := NewService(store, nil, nil)
s.activeTunnels[1] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50010,
LastActivity: time.Now(),
}
s.checkTunnels()
require.Nil(t, s.activeTunnels[1], "stale tunnel for deleted endpoint must be removed immediately")
}
func TestCheckTunnelsClosesIdleTunnelAndSnapshots(t *testing.T) {
t.Parallel()

View File

@@ -82,24 +82,17 @@ func (s *Service) Open(endpoint *portainer.Endpoint) error {
return nil
}
// close removes the tunnel from the map so the agent will close it.
// The lock is released before cleaning up the chisel user and proxy to avoid
// blocking Config/Open callers while DeleteUser interacts with chisel internals.
// close removes the tunnel from the map so the agent will close it
func (s *Service) close(endpointID portainer.EndpointID) {
s.mu.Lock()
defer s.mu.Unlock()
tun, ok := s.activeTunnels[endpointID]
if !ok {
s.mu.Unlock()
return
}
delete(s.activeTunnels, endpointID)
cache.Del(endpointID)
s.mu.Unlock()
if s.chiselServer != nil {
if len(tun.Credentials) > 0 && s.chiselServer != nil {
user, _, _ := strings.Cut(tun.Credentials, ":")
s.chiselServer.DeleteUser(user)
}
@@ -107,6 +100,10 @@ func (s *Service) close(endpointID portainer.EndpointID) {
if s.ProxyManager != nil {
s.ProxyManager.DeleteEndpointProxy(endpointID)
}
delete(s.activeTunnels, endpointID)
cache.Del(endpointID)
}
// Config returns the tunnel details needed for the agent to connect

View File

@@ -56,6 +56,8 @@ func CLIFlags() *portainer.CLIFlags {
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(),
SSRFMode: kingpin.Flag("ssrf-mode", "SSRF protection mode: off (disabled), audit (log violations but allow), enforce (block violations)").Envar("PORTAINER_SSRF_MODE").Default("off").Enum("off", "audit", "enforce"),
SSRFAllowedHosts: kingpin.Flag("ssrf-allowed-hosts", "Allowlist of hostnames (with optional wildcards), IPs, or CIDRs permitted for outbound requests. When empty and mode is enforce, all outbound connections are blocked").Envar("PORTAINER_SSRF_ALLOWED_HOSTS").Strings(),
NoSetupToken: kingpin.Flag("no-setup-token", "Disable the setup token requirement for admin initialization and restore on an uninitialized instance").Envar(portainer.NoSetupTokenEnvVar).Bool(),
SetupToken: kingpin.Flag("setup-token", "Set a custom setup token for admin initialization and restore on an uninitialized instance (overrides auto-generation)").Envar(portainer.SetupTokenEnvVar).String(),
}

View File

@@ -58,10 +58,7 @@ import (
libswarm "github.com/portainer/portainer/pkg/libstack/swarm"
"github.com/portainer/portainer/pkg/validate"
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
gogitraw "github.com/go-git/go-git/v5/plumbing/transport/git"
gogithttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gogitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
@@ -390,6 +387,19 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
// -ce can not ever be run in FIPS mode
fips.InitFIPS(false)
ssrf.Configure(ssrf.Policy{
Mode: ssrf.Mode(*flags.SSRFMode),
AllowedHosts: *flags.SSRFAllowedHosts,
})
if ssrf.IsEnabled() {
if dt, ok := nethttp.DefaultTransport.(*nethttp.Transport); ok {
nethttp.DefaultTransport = ssrf.WrapTransport(dt)
}
gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport})
}
fileService := initFileService(*flags.Data)
encryptionKey := loadEncryptionSecretKey(dbSecretPath(*flags.SecretKeyName))
if encryptionKey == nil {
@@ -407,19 +417,6 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
}
if err := ssrf.Configure(dataStore.AllowList()); err != nil {
log.Fatal().Err(err).Msg("failed initializing ssrf service")
}
if !ssrf.WrapDefaultTransport() {
log.Fatal().Msg("failed to wrap default HTTP transport with SSRF protection")
}
gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport})
gogitclient.InstallProtocol("git", git.NewSSRFGitTransport(gogitraw.DefaultClient))
gogitclient.InstallProtocol("ssh", git.NewSSRFGitTransport(gogitssh.DefaultClient))
gogitclient.InstallProtocol("file", nil)
instanceID, err := dataStore.Version().InstanceID()
if err != nil {
log.Fatal().Err(err).Msg("failed getting instance id")

View File

@@ -1,131 +0,0 @@
package allowlist
import (
"fmt"
lru "github.com/hashicorp/golang-lru"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
const (
BucketName = "allowlist"
)
type Service struct {
baseService dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey]
cache *lru.Cache
}
func (service *Service) BucketName() string {
return service.baseService.BucketName()
}
func NewService(connection portainer.Connection) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
return nil, err
}
service := &Service{
baseService: dataservices.BaseDataService[portainer.AllowList, portainer.AllowListKey]{
Bucket: BucketName,
Connection: connection,
},
}
err = service.populateCache()
return service, err
}
func (service *Service) populateCache() error {
allowListKeys := []portainer.AllowListKey{portainer.AllowListSSRF}
cache, err := lru.New(len(allowListKeys))
if err != nil {
return err
}
for _, k := range allowListKeys {
allowList, err := service.baseService.Read(k)
if dataservices.IsErrObjectNotFound(err) {
allowList = &portainer.AllowList{
ID: k,
Mode: portainer.SSRFModeOff,
Entries: []string{},
}
} else if err != nil {
return err
}
parsedAllowList := ssrf.ParseAllowedHosts(allowList.Entries)
parsedAllowList.Mode = allowList.Mode
cache.Add(k, &parsedAllowList)
}
service.cache = cache
return nil
}
func (service *Service) Tx(tx portainer.Transaction) *ServiceTx {
return &ServiceTx{
baseService: service.baseService.Tx(tx),
cache: service.cache,
}
}
func (service *Service) Read(id portainer.AllowListKey) (*portainer.AllowList, error) {
var result *portainer.AllowList
if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).Read(id)
return err
}); err != nil {
return nil, err
}
return result, nil
}
func (service *Service) ReadAll() ([]portainer.AllowList, error) {
var result []portainer.AllowList
if err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).ReadAll()
return err
}); err != nil {
return nil, err
}
return result, nil
}
func (service *Service) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
allowListAny, ok := service.cache.Get(id)
if ok {
allowList, ok := allowListAny.(*portainer.ParsedAllowList)
if !ok {
return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny)
}
return allowList, nil
}
var result *portainer.ParsedAllowList
err := service.baseService.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).ReadParsed(id)
return err
})
return result, err
}
func (service *Service) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error {
return service.baseService.Connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).Update(id, allowList)
})
}

View File

@@ -1,89 +0,0 @@
package allowlist_test
import (
"net"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestAllowListReadEmpty(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
got, err := ds.AllowList().Read(portainer.AllowListSSRF)
expected := &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeOff,
Entries: []string{},
}
require.NoError(t, err)
require.Equal(t, expected, got)
}
func TestAllowListUpdate(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
expected := &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com", "10.0.0.0/8"},
}
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, expected))
got, err := ds.AllowList().Read(portainer.AllowListSSRF)
require.NoError(t, err)
require.Equal(t, expected, got)
}
func TestAllowListReadAllEmpty(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
got, err := ds.AllowList().ReadAll()
require.NoError(t, err)
require.Equal(t, []portainer.AllowList{}, got)
}
func TestAllowListReadAllAfterUpdate(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
expected := portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com", "10.0.0.0/8"},
}
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &expected))
got, err := ds.AllowList().ReadAll()
require.NoError(t, err)
require.Equal(t, []portainer.AllowList{expected}, got)
}
func TestAllowListReadParsedAfterUpdate(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
require.NoError(t, ds.AllowList().Update(portainer.AllowListSSRF, &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com"},
}))
expected := &portainer.ParsedAllowList{
Mode: portainer.SSRFModeEnforce,
Nets: []*net.IPNet{},
Hosts: map[string]bool{
"example.com": true,
},
}
got, err := ds.AllowList().ReadParsed(portainer.AllowListSSRF)
require.NoError(t, err)
require.Equal(t, expected, got)
}

View File

@@ -1,77 +0,0 @@
package allowlist
import (
"fmt"
lru "github.com/hashicorp/golang-lru"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
type ServiceTx struct {
baseService dataservices.BaseDataServiceTx[portainer.AllowList, portainer.AllowListKey]
cache *lru.Cache
}
func (service *ServiceTx) BucketName() string {
return service.baseService.BucketName()
}
func (service *ServiceTx) ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error) {
allowListAny, ok := service.cache.Get(id)
if ok {
allowList, ok := allowListAny.(*portainer.ParsedAllowList)
if !ok {
return nil, fmt.Errorf("expected ParsedAllowList in cache but got %T", allowListAny)
}
return allowList, nil
}
allowList, err := service.Read(id)
if err != nil {
return nil, err
}
parsed := ssrf.ParseAllowedHosts(allowList.Entries)
parsed.Mode = allowList.Mode
service.cache.Add(id, &parsed)
return &parsed, nil
}
func (service *ServiceTx) Read(id portainer.AllowListKey) (*portainer.AllowList, error) {
allowList, err := service.baseService.Read(id)
if dataservices.IsErrObjectNotFound(err) {
allowList = &portainer.AllowList{
ID: id,
Mode: portainer.SSRFModeOff,
Entries: []string{},
}
} else if err != nil {
return nil, err
}
return allowList, nil
}
func (service *ServiceTx) ReadAll() ([]portainer.AllowList, error) {
allowLists, err := service.baseService.ReadAll()
if err != nil && !dataservices.IsErrObjectNotFound(err) {
return nil, err
}
return allowLists, nil
}
func (service *ServiceTx) Update(id portainer.AllowListKey, allowList *portainer.AllowList) error {
if err := service.baseService.Update(id, allowList); err != nil {
return err
}
parsed := ssrf.ParseAllowedHosts(allowList.Entries)
parsed.Mode = allowList.Mode
service.cache.Add(id, &parsed)
return nil
}

View File

@@ -1,92 +0,0 @@
package allowlist_test
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestAllowListReadTx(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
var got *portainer.AllowList
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
got, err = tx.AllowList().Read(portainer.AllowListSSRF)
return err
}))
expected := &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeOff,
Entries: []string{},
}
require.Equal(t, expected, got)
}
func TestAllowListReadAllEmptyTx(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
var got []portainer.AllowList
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
got, err = tx.AllowList().ReadAll()
return err
}))
require.Equal(t, []portainer.AllowList{}, got)
}
func TestAllowListReadAllAfterUpdateTx(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
expected := portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com"},
}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.AllowList().Update(portainer.AllowListSSRF, &expected)
}))
var got []portainer.AllowList
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
got, err = tx.AllowList().ReadAll()
return err
}))
require.Equal(t, []portainer.AllowList{expected}, got)
}
func TestAllowListUpdateTx(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, false, false)
expected := &portainer.AllowList{
ID: portainer.AllowListSSRF,
Mode: portainer.SSRFModeEnforce,
Entries: []string{"example.com"},
}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.AllowList().Update(portainer.AllowListSSRF, expected)
}))
var got *portainer.AllowList
require.NoError(t, ds.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
got, err = tx.AllowList().Read(portainer.AllowListSSRF)
return err
}))
require.Equal(t, expected, got)
}

View File

@@ -8,7 +8,6 @@ import (
type (
DataStoreTx interface {
IsErrObjectNotFound(err error) bool
AllowList() AllowListService
CustomTemplate() CustomTemplateService
EdgeGroup() EdgeGroupService
EdgeJob() EdgeJobService
@@ -54,15 +53,6 @@ type (
DataStoreTx
}
// AllowListService represents a service for managing the URL allow list
AllowListService interface {
Read(id portainer.AllowListKey) (*portainer.AllowList, error)
ReadAll() ([]portainer.AllowList, error)
ReadParsed(id portainer.AllowListKey) (*portainer.ParsedAllowList, error)
Update(id portainer.AllowListKey, allowList *portainer.AllowList) error
BucketName() string
}
// CustomTemplateService represents a service to manage custom templates
CustomTemplateService interface {
BaseCRUD[portainer.CustomTemplate, portainer.CustomTemplateID]

View File

@@ -24,6 +24,7 @@ type legacyGitAuthentication struct {
Password string
Provider int `json:",omitempty"`
AuthorizationType int `json:",omitempty"`
GitCredentialID int
}
func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig {
@@ -40,6 +41,12 @@ func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig {
}
if lrc.Authentication != nil {
if lrc.Authentication.GitCredentialID != 0 {
log.Warn().
Int("git_credential_id", lrc.Authentication.GitCredentialID).
Msg("stack has a GitCredentialID reference which is not supported in CE; credential reference will be dropped during migration")
}
cfg.Authentication = &gittypes.GitAuthentication{
Username: lrc.Authentication.Username,
Password: lrc.Authentication.Password,
@@ -206,6 +213,14 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
TLSSkipVerify: t.GitConfig.TLSSkipVerify,
}
if cfg.Authentication != nil && cfg.Authentication.GitCredentialID != 0 {
log.Warn().
Int("git_credential_id", cfg.Authentication.GitCredentialID).
Msg("custom template has a GitCredentialID reference which is not supported in CE; credential reference will be dropped during migration")
cfg.Authentication.GitCredentialID = 0
}
key := gitSourceKey(cfg)
var newSrcID portainer.SourceID

View File

@@ -7,7 +7,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/allowlist"
"github.com/portainer/portainer/api/dataservices/apikeyrepository"
"github.com/portainer/portainer/api/dataservices/customtemplate"
"github.com/portainer/portainer/api/dataservices/dockerhub"
@@ -52,7 +51,6 @@ type Store struct {
connection portainer.Connection
fileService portainer.FileService
AllowListService *allowlist.Service
CustomTemplateService *customtemplate.Service
DockerHubService *dockerhub.Service
EdgeGroupService *edgegroup.Service
@@ -86,12 +84,6 @@ type Store struct {
}
func (store *Store) initServices() error {
allowListService, err := allowlist.NewService(store.connection)
if err != nil {
return err
}
store.AllowListService = allowListService
authorizationsetService, err := role.NewService(store.connection)
if err != nil {
return err
@@ -283,11 +275,6 @@ func (store *Store) PendingActions() dataservices.PendingActionsService {
return store.PendingActionsService
}
// AllowList gives access to the AllowList data management layer
func (store *Store) AllowList() dataservices.AllowListService {
return store.AllowListService
}
// CustomTemplate gives access to the CustomTemplate data management layer
func (store *Store) CustomTemplate() dataservices.CustomTemplateService {
return store.CustomTemplateService
@@ -667,7 +654,7 @@ func (store *Store) Export(filename string) (err error) {
return err
}
return os.WriteFile(filename, b, 0o600)
return os.WriteFile(filename, b, 0600)
}
func (store *Store) Import(filename string) (err error) {

View File

@@ -14,10 +14,6 @@ func (tx *StoreTx) IsErrObjectNotFound(err error) bool {
return tx.store.IsErrObjectNotFound(err)
}
func (tx *StoreTx) AllowList() dataservices.AllowListService {
return tx.store.AllowListService.Tx(tx.tx)
}
func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService {
return tx.store.CustomTemplateService.Tx(tx.tx)
}

View File

@@ -1,5 +1,4 @@
{
"allowlist": null,
"api_key": null,
"customtemplates": null,
"dockerhub": [

View File

@@ -90,7 +90,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
client.WithHTTPClient(httpCli),
}
if endpoint.TLSConfig.TLS {
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
opts = append(opts, client.WithScheme("https"))
}
@@ -124,7 +124,7 @@ func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatu
client.WithHTTPHeaders(headers),
}
if endpoint.TLSConfig.TLS {
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
opts = append(opts, client.WithScheme("https"))
}
@@ -194,11 +194,11 @@ func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Cli
}
transport = &NodeNameTransport{
Transport: ssrf.NewTransport(tlsConfig),
Transport: ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig}),
}
} else {
transport = &NodeNameTransport{
Transport: ssrf.NewTransport(nil),
Transport: ssrf.WrapTransport(&http.Transport{}),
}
}

View File

@@ -26,12 +26,7 @@ func NewRegistryClient(dataStore dataservices.DataStore) *RegistryClient {
func (c *RegistryClient) RegistryAuth(image Image) (string, string, error) {
registry, err := cachedRegistry(image.Opts.Name)
if err != nil {
var registries []portainer.Registry
err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
registries, err = tx.Registry().ReadAll()
return err
})
registries, err := c.dataStore.Registry().ReadAll()
if err != nil {
return "", "", err
}
@@ -64,12 +59,7 @@ func (c *RegistryClient) CertainRegistryAuth(registry *portainer.Registry) (stri
func (c *RegistryClient) EncodedRegistryAuth(image Image) (string, error) {
registry, err := cachedRegistry(image.Opts.Name)
if err != nil {
var registries []portainer.Registry
err = c.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
registries, err = tx.Registry().ReadAll()
return err
})
registries, err := c.dataStore.Registry().ReadAll()
if err != nil {
return "", err
}

View File

@@ -89,7 +89,7 @@ func JoinPaths(trustedRoot string, untrustedPaths ...string) string {
trustedRoot = "."
}
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...))
p := filepath.Join(trustedRoot, filepath.Join(append([]string{"/"}, untrustedPaths...)...)) //nolint:forbidigo
// avoid setting a volume name from the untrusted paths
vnp := filepath.VolumeName(p)

View File

@@ -66,8 +66,11 @@ func NewAzureClient() *azureClient {
func newHttpClientForAzure(insecureSkipVerify bool) *http.Client {
return &http.Client{
Transport: ssrf.NewTransport(crypto.CreateTLSConfiguration(insecureSkipVerify)),
Timeout: 300 * time.Second,
Transport: ssrf.WrapTransport(&http.Transport{
TLSClientConfig: crypto.CreateTLSConfiguration(insecureSkipVerify),
Proxy: http.ProxyFromEnvironment,
}),
Timeout: 300 * time.Second,
}
}

View File

@@ -2,24 +2,18 @@ package git
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
@@ -48,35 +42,28 @@ func NewGitClient(preserveGitDir bool) *gitClient {
}
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
resolved, err := filepath.EvalSymlinks(dst)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return errors.Wrap(err, "failed to resolve destination path")
}
if err == nil {
dst = resolved
if c.preserveGitDirectory {
_, err := git.PlainCloneContext(ctx, dst, false, opt)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
return nil
}
// Memory storage avoids a macOS filesystem conflict where go-git's init
// creates dst/.git as a directory before checkout, causing EISDIR errors
// that mask ErrSymlinkDetected from noSymlinkFS.
wt := NewNoSymlinkFS(osfs.New(dst))
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
_, err = git.CloneContext(ctx, storer, wt, opt)
_, err := git.CloneContext(ctx, memory.NewStorage(), wt, opt)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
if c.preserveGitDirectory {
return nil
}
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
return nil
}
@@ -118,7 +105,7 @@ func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git
URLs: []string{repositoryUrl},
})
refs, err := rem.ListContext(ctx, opt)
refs, err := rem.List(opt)
if err != nil {
return nil, checkGitError(err)
}

View File

@@ -99,19 +99,6 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
}
func Test_ClonePublicRepository_NonExistentDst(t *testing.T) {
t.Parallel()
service := Service{git: NewGitClient(false)}
repositoryURL := setup(t)
referenceName := "refs/heads/main"
dir := filesystem.JoinPaths(t.TempDir(), "sub", "dir")
err := service.CloneRepository(t.Context(), dir, repositoryURL, referenceName, "", "", false)
require.NoError(t, err)
assert.DirExists(t, dir)
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
}
func Test_latestCommitID(t *testing.T) {
t.Parallel()
service := Service{git: NewGitClient(true)} // no need for http client since the test access the repo via file system.
@@ -275,7 +262,6 @@ func createBareRepoWithSymlink(t *testing.T) string {
}
func Test_Download_RejectsSymlink(t *testing.T) {
t.Parallel()
client := NewGitClient(false)
repoURL := createBareRepoWithSymlink(t)

View File

@@ -1,53 +0,0 @@
package git
import (
"context"
"fmt"
"net"
"strconv"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
gittransport "github.com/go-git/go-git/v5/plumbing/transport"
)
const gitDefaultPort = 9418
// ssrfGitTransport wraps a git:// transport and validates the resolved IP
// against the SSRF policy before establishing connections.
type ssrfGitTransport struct {
inner gittransport.Transport
}
// NewSSRFGitTransport wraps inner and blocks connections to private IP ranges
// according to the active SSRF policy.
func NewSSRFGitTransport(inner gittransport.Transport) gittransport.Transport {
return &ssrfGitTransport{inner: inner}
}
func (t *ssrfGitTransport) NewUploadPackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.UploadPackSession, error) {
if err := checkEndpointSSRF(ep); err != nil {
return nil, err
}
return t.inner.NewUploadPackSession(ep, auth)
}
func (t *ssrfGitTransport) NewReceivePackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.ReceivePackSession, error) {
if err := checkEndpointSSRF(ep); err != nil {
return nil, err
}
return t.inner.NewReceivePackSession(ep, auth)
}
func checkEndpointSSRF(ep *gittransport.Endpoint) error {
port := ep.Port
if port <= 0 {
port = gitDefaultPort
}
rawURL := fmt.Sprintf("git://%s/", net.JoinHostPort(ep.Host, strconv.Itoa(port)))
return ssrf.CheckURL(context.Background(), rawURL)
}

View File

@@ -96,4 +96,8 @@ type GitAuthentication struct {
Password string
Provider GitProvider `json:",omitempty"`
AuthorizationType GitCredentialAuthType `json:",omitempty"`
// Git credentials identifier when the value is not 0
// When the value is 0, Username and Password are set without using saved credential
// This is introduced since 2.15.0
GitCredentialID int `example:"0"`
}

View File

@@ -1,55 +0,0 @@
package sources
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// RepoConfigInput holds the raw payload fields needed to resolve a git RepoConfig.
// Set SourceID to resolve URL/auth from a stored source; otherwise provide the inline fields.
type RepoConfigInput struct {
SourceID portainer.SourceID
ReferenceName string
ConfigFilePath string
RepositoryURL string
TLSSkipVerify bool
RepositoryAuthentication bool
Username string
Password string
Provider gittypes.GitProvider
AuthorizationType gittypes.GitCredentialAuthType
}
// ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields.
func ResolveRepoConfig(tx gitSourceStore, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) {
cfg := gittypes.RepoConfig{
ReferenceName: input.ReferenceName,
ConfigFilePath: input.ConfigFilePath,
}
if input.SourceID != 0 {
src, httpErr := ValidateGitSourceAccess(tx, input.SourceID)
if httpErr != nil {
return gittypes.RepoConfig{}, httpErr
}
cfg.URL = src.Git.URL
cfg.Authentication = src.Git.Authentication
cfg.TLSSkipVerify = src.Git.TLSSkipVerify
} else {
cfg.URL = input.RepositoryURL
cfg.TLSSkipVerify = input.TLSSkipVerify
if input.RepositoryAuthentication {
cfg.Authentication = &gittypes.GitAuthentication{
Username: input.Username,
Password: input.Password,
Provider: input.Provider,
AuthorizationType: input.AuthorizationType,
}
}
}
cfg.TLSSkipVerify = cfg.TLSSkipVerify && fips.CanTLSSkipVerify()
return cfg, nil
}

View File

@@ -1,70 +0,0 @@
package sources
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func init() {
fips.InitFIPS(false)
}
func TestResolveRepoConfig_WithSourceID_ReturnsSourceConfig(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/org/repo",
TLSSkipVerify: true,
Authentication: &gittypes.GitAuthentication{
Username: "user",
Password: "token",
},
},
}
require.NoError(t, store.Source().Create(src))
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{
SourceID: src.ID,
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
RepositoryURL: "https://ignored.example.com",
})
require.Nil(t, httpErr)
assert.Equal(t, src.Git.URL, cfg.URL)
assert.Equal(t, src.Git.Authentication, cfg.Authentication)
assert.Equal(t, src.Git.TLSSkipVerify, cfg.TLSSkipVerify)
assert.Equal(t, "refs/heads/main", cfg.ReferenceName)
assert.Equal(t, "docker-compose.yml", cfg.ConfigFilePath)
}
func TestResolveRepoConfig_WithInlineURL_ReturnsInlineConfig(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
RepositoryURL: "https://github.com/org/repo",
TLSSkipVerify: true,
RepositoryAuthentication: true,
Username: "user",
Password: "pass",
})
require.Nil(t, httpErr)
assert.Equal(t, "https://github.com/org/repo", cfg.URL)
assert.True(t, cfg.TLSSkipVerify)
require.NotNil(t, cfg.Authentication)
assert.Equal(t, "user", cfg.Authentication.Username)
assert.Equal(t, "pass", cfg.Authentication.Password)
}

View File

@@ -1,38 +0,0 @@
package sources
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need.
// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types.
type gitSourceStore interface {
Source() dataservices.SourceService
IsErrObjectNotFound(err error) bool
}
// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it.
// TODO(BE-12905): enforce per-user access policies once Source ownership is introduced.
func ValidateGitSourceAccess(tx gitSourceStore, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
src, err := tx.Source().Read(sourceID)
if err != nil {
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Source not found", err)
}
return nil, httperror.InternalServerError("Unable to read source", err)
}
if src.Type != portainer.SourceTypeGit {
return nil, httperror.BadRequest(fmt.Sprintf("source %d is not a git source", sourceID), nil)
}
if src.Git == nil {
return nil, httperror.BadRequest("Source has no git configuration", nil)
}
return src, nil
}

View File

@@ -1,49 +0,0 @@
package sources
import (
"net/http"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo"},
}
require.NoError(t, store.Source().Create(src))
_, httpErr := ValidateGitSourceAccess(store, src.ID)
assert.Nil(t, httpErr)
}
func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
_, httpErr := ValidateGitSourceAccess(store, portainer.SourceID(999))
require.NotNil(t, httpErr)
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
}
func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceType(99), // not a git source
}
require.NoError(t, store.Source().Create(src))
_, httpErr := ValidateGitSourceAccess(store, src.ID)
require.NotNil(t, httpErr)
assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode)
}

View File

@@ -16,8 +16,6 @@ import (
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
)
func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
@@ -117,8 +115,7 @@ func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.Restrict
access, err := resolveKubeAccess(k8sFactory, sc, &ep)
if err != nil {
log.Warn().Err(err).Str("context", "buildEndpointAccessMap").Int("endpoint_id", int(epID)).Msg("Failed to resolve kube access for endpoint, skipping")
continue
return nil, err
}
result[epID] = access
@@ -151,8 +148,7 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
if err != nil {
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube client for endpoint, skipping")
continue
return nil, err
}
access := accessMap[envID]
@@ -161,8 +157,7 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint
apps, err := kcl.GetApplications("", "")
if err != nil {
log.Warn().Err(err).Str("context", "filterK8SStacks").Int("endpoint_id", int(envID)).Msg("Failed to get kube applications for endpoint, skipping")
continue
return nil, err
}
for _, s := range stacks {

View File

@@ -10,7 +10,6 @@ import (
)
// gitSourceStore is the minimal intersection of CE and EE DataStoreTx that these functions need.
// Both EE and CE DataStoreTx satisfy it, even though they are incompatible as full interface types.
type gitSourceStore interface {
Workflow() dataservices.WorkflowService
Source() dataservices.SourceService
@@ -238,19 +237,6 @@ func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, m
}
}
return SaveWorkflowArtifact(tx, workflowID, matchArtifact, oldSourceID, portainer.ArtifactFile{
SourceID: newSourceID,
Ref: cfg.ReferenceName,
Path: cfg.ConfigFilePath,
Hash: cfg.ConfigHash,
})
}
// SaveWorkflowArtifact replaces the ArtifactFile referencing oldSourceID on the Artifact matched by
// matchArtifact with update (its SourceID may repoint the Artifact to a different Source). It does not
// modify any Source's git config — the caller is responsible for ensuring update.SourceID
// references a valid existing Source.
func SaveWorkflowArtifact(tx gitSourceStore, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, update portainer.ArtifactFile) error {
wf, err := tx.Workflow().Read(workflowID)
if err != nil {
return fmt.Errorf("failed to read workflow: %w", err)
@@ -266,11 +252,13 @@ func SaveWorkflowArtifact(tx gitSourceStore, workflowID portainer.WorkflowID, ma
continue
}
f := &wf.Artifacts[i].Files[j]
f.SourceID = update.SourceID
f.Ref = update.Ref
f.Path = update.Path
f.Hash = update.Hash
wf.Artifacts[i].Files[j].Ref = cfg.ReferenceName
wf.Artifacts[i].Files[j].Path = cfg.ConfigFilePath
wf.Artifacts[i].Files[j].Hash = cfg.ConfigHash
if newSourceID != oldSourceID {
wf.Artifacts[i].Files[j].SourceID = newSourceID
}
break
}
@@ -357,12 +345,11 @@ func gitAuthMatches(a, b *gittypes.GitAuthentication) bool {
return false
}
return a.Username == b.Username && a.Password == b.Password
return a.Username == b.Username && a.Password == b.Password && a.GitCredentialID == b.GitCredentialID
}
// ValidateUniqueSource validates there are no other sources with the same URL and credentials.
// Pass empty strings for username and password when the source has no authentication.
func ValidateUniqueSource(tx gitSourceStore, url, username, password string, sourceID portainer.SourceID) (bool, error) {
// ValidateUniqueSourceURL validates there are no other sources with the same URL
func ValidateUniqueSourceURL(tx gitSourceStore, url string, sourceID portainer.SourceID) (bool, error) {
normalizedURL, err := gittypes.NormalizeURL(gittypes.SanitizeURL(url))
if err != nil {
return false, err
@@ -374,12 +361,8 @@ func ValidateUniqueSource(tx gitSourceStore, url, username, password string, sou
}
normalized, err := gittypes.NormalizeURL(gittypes.SanitizeURL(s.Git.URL))
if err != nil || normalized != normalizedURL {
return false
}
existingUsername, existingPassword := gitAuthCredentials(s.Git.Authentication)
return existingUsername == username && existingPassword == password
return err == nil && normalized == normalizedURL
})
if err != nil {
@@ -388,10 +371,3 @@ func ValidateUniqueSource(tx gitSourceStore, url, username, password string, sou
return len(existing) == 0, nil
}
func gitAuthCredentials(auth *gittypes.GitAuthentication) (username, password string) {
if auth == nil {
return "", ""
}
return auth.Username, auth.Password
}

View File

@@ -751,318 +751,6 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
require.Equal(t, "hash-2", wf.Artifacts[1].Files[0].Hash)
}
func TestUpdateArtifactFileForStack_MultipleArtifactsOnlyMatchingUpdated(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{
{StackID: 10, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-10"}}},
{StackID: 20, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-20"}}},
},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return UpdateArtifactFileForStack(tx, workflowID, 10, srcID, func(a *portainer.ArtifactFile) {
a.Hash = "updated-hash-10"
})
})
require.NoError(t, err)
wf, err := store.Workflow().Read(workflowID)
require.NoError(t, err)
require.Equal(t, "updated-hash-10", wf.Artifacts[0].Files[0].Hash)
require.Equal(t, "hash-20", wf.Artifacts[1].Files[0].Hash)
}
func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
var oldSourceID, newSourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// Two distinct sources sharing the same URL: the case where URL-based
// resolution would fail to switch.
old := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(old)
require.NoError(t, err)
oldSourceID = old.ID
selected := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
Authentication: &gittypes.GitAuthentication{
Username: "selected-user",
Password: "selected-pass",
},
},
}
err = tx.Source().Create(selected)
require.NoError(t, err)
newSourceID = selected.ID
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{{
StackID: 1,
Files: []portainer.ArtifactFile{{
SourceID: oldSourceID,
Ref: "refs/heads/main",
Path: "docker-compose.yml",
Hash: "old-hash",
}},
}},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowArtifact(tx, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, oldSourceID, portainer.ArtifactFile{
SourceID: newSourceID,
Ref: "refs/heads/dev",
Path: "compose.yml",
Hash: "new-hash",
})
})
require.NoError(t, err)
wf, err := store.Workflow().Read(workflowID)
require.NoError(t, err)
require.Equal(t, newSourceID, wf.Artifacts[0].Files[0].SourceID)
require.Equal(t, "refs/heads/dev", wf.Artifacts[0].Files[0].Ref)
require.Equal(t, "compose.yml", wf.Artifacts[0].Files[0].Path)
require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash)
// The selected source's git config must be left untouched.
selected, err := store.Source().Read(newSourceID)
require.NoError(t, err)
require.Equal(t, "https://github.com/example/repo", selected.Git.URL)
require.Equal(t, "selected-user", selected.Git.Authentication.Username)
require.Equal(t, "selected-pass", selected.Git.Authentication.Password)
}
func TestUpdateArtifactFileForEdgeStack_MultipleArtifactsOnlyMatchingUpdated(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{
{EdgeStackID: 10, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-10"}}},
{EdgeStackID: 20, Files: []portainer.ArtifactFile{{SourceID: srcID, Hash: "hash-20"}}},
},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return UpdateArtifactFileForEdgeStack(tx, workflowID, 10, srcID, func(a *portainer.ArtifactFile) {
a.Hash = "updated-hash-10"
})
})
require.NoError(t, err)
wf, err := store.Workflow().Read(workflowID)
require.NoError(t, err)
require.Equal(t, "updated-hash-10", wf.Artifacts[0].Files[0].Hash)
require.Equal(t, "hash-20", wf.Artifacts[1].Files[0].Hash)
}
func TestSaveWorkflowArtifact_SameSourceUpdatesArtifactOnly(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
var sourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(src)
require.NoError(t, err)
sourceID = src.ID
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{{
StackID: 1,
Files: []portainer.ArtifactFile{{
SourceID: sourceID,
Ref: "refs/heads/main",
}},
}},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowArtifact(tx, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, sourceID, portainer.ArtifactFile{
SourceID: sourceID,
Ref: "refs/heads/dev",
Path: "compose.yml",
Hash: "new-hash",
})
})
require.NoError(t, err)
wf, err := store.Workflow().Read(workflowID)
require.NoError(t, err)
require.Len(t, wf.Artifacts[0].Files, 1)
require.Equal(t, sourceID, wf.Artifacts[0].Files[0].SourceID)
require.Equal(t, "refs/heads/dev", wf.Artifacts[0].Files[0].Ref)
require.Equal(t, "compose.yml", wf.Artifacts[0].Files[0].Path)
require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash)
}
func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
gitSrc := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"},
}
err := tx.Source().Create(gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{
{StackID: 10, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/main", Hash: "hash-10"}}},
{StackID: 20, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/dev", Hash: "hash-20"}}},
},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
var src *portainer.Source
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 20)
return txErr
})
require.NoError(t, err)
require.NotNil(t, src)
require.NotNil(t, file)
require.Equal(t, "refs/heads/dev", file.Ref)
require.Equal(t, "hash-20", file.Hash)
}
func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
gitSrc := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-edge-repo"},
}
err := tx.Source().Create(gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{
{EdgeStackID: 10, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/main", Hash: "hash-10"}}},
{EdgeStackID: 20, Files: []portainer.ArtifactFile{{SourceID: gitSrc.ID, Ref: "refs/heads/dev", Hash: "hash-20"}}},
},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
var src *portainer.Source
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, workflowID, 20)
return txErr
})
require.NoError(t, err)
require.NotNil(t, src)
require.NotNil(t, file)
require.Equal(t, "refs/heads/dev", file.Ref)
require.Equal(t, "hash-20", file.Hash)
}
func TestMergeSourceAndFile_ConfigHashComesFromFileNotSource(t *testing.T) {
t.Parallel()
// ConfigHash must come from ArtifactFile.Hash, not src.Git.
// A Source shared by two stacks has one Git.ConfigHash field;
// if reads used it instead of ArtifactFile.Hash they would clobber each other.
src := &portainer.Source{
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
}
file := &portainer.ArtifactFile{
Hash: "artifact-hash",
}
cfg := MergeSourceAndFile(src, file)
require.NotNil(t, cfg)
require.Equal(t, "artifact-hash", cfg.ConfigHash)
}
func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
@@ -1081,97 +769,3 @@ func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "https://github.com/example/repo", src.Git.URL)
}
func newSourceWithAuth(url, username, password string) *portainer.Source {
return &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: url,
Authentication: &gittypes.GitAuthentication{
Username: username,
Password: password,
},
},
}
}
func newAuthlessSource(url string) *portainer.Source {
return &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: url},
}
}
func validateUniqueSourceInStore(t *testing.T, store *datastore.Store, url, username, password string, sourceID portainer.SourceID) bool {
t.Helper()
var isUnique bool
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
isUnique, err = ValidateUniqueSource(tx, url, username, password, sourceID)
return err
}))
return isUnique
}
func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
}))
require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
}
func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0))
}
func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git"))
}))
require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0))
}
func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git"))
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
}
func TestValidateUniqueSource_ExcludesSelf(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")
if err := tx.Source().Create(src); err != nil {
return err
}
srcID = src.ID
return nil
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID))
}

View File

@@ -125,9 +125,9 @@ func ExecutePingOperation(host string, tlsConfiguration portainer.TLSConfigurati
}
scheme = "https"
transport = ssrf.NewTransport(tlsConfig)
transport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
} else {
transport = ssrf.NewTransport(nil)
transport = ssrf.WrapTransport(&http.Transport{})
}
client := &http.Client{

View File

@@ -11,7 +11,6 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
@@ -19,7 +18,6 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/portainer/portainer/pkg/validate"
"github.com/rs/zerolog/log"
@@ -204,24 +202,21 @@ type customTemplateFromGitRepositoryPayload struct {
// * 3 - kubernetes
Type portainer.StackType `example:"1" enums:"1,2" validate:"required"`
// SourceID references an existing Source for git credentials/URL.
// When set, the inline URL and authentication fields are ignored.
SourceID portainer.SourceID `example:"1" validate:"required"`
// Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
RepositoryURL string `example:"https://github.com/openfaas/faas"`
// URL of a Git repository hosting the Stack file
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
// Reference name of a Git repository hosting the Stack file
RepositoryReferenceName string `example:"refs/heads/master"`
// Deprecated: use SourceID instead. Use basic authentication to clone the Git repository.
// Use basic authentication to clone the Git repository
RepositoryAuthentication bool `example:"true"`
// Deprecated: use SourceID instead. Username used in basic authentication. Required when RepositoryAuthentication is true.
// Username used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryUsername string `example:"myGitUsername"`
// Deprecated: use SourceID instead. Password used in basic authentication. Required when RepositoryAuthentication is true.
// Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string `example:"myGitPassword"`
// Path to the Stack file inside the Git repository
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
// Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
IsComposeFormat bool `example:"false"`
@@ -236,13 +231,11 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
if len(payload.Description) == 0 {
return errors.New("Invalid custom template description")
}
if payload.SourceID == 0 {
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
}
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
}
if len(payload.ComposeFilePathInRepository) == 0 {
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
@@ -302,49 +295,41 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
projectPath := getProjectPath()
customTemplate.ProjectPath = projectPath
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{
SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.ComposeFilePathInRepository,
RepositoryURL: payload.RepositoryURL,
TLSSkipVerify: payload.TLSSkipVerify,
RepositoryAuthentication: payload.RepositoryAuthentication,
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
})
if httpErr != nil {
return nil, httpErr
gitConfig := &gittypes.RepoConfig{
URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.ComposeFilePathInRepository,
TLSSkipVerify: payload.TLSSkipVerify,
}
if err := ssrf.CheckURL(r.Context(), gitConfig.URL); err != nil {
return nil, err
if payload.RepositoryAuthentication {
gitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
}
}
commitHash, err := stackutils.DownloadGitRepository(context.TODO(), gitConfig, handler.GitService, getProjectPath)
commitHash, err := stackutils.DownloadGitRepository(context.TODO(), *gitConfig, handler.GitService, getProjectPath)
if err != nil {
return nil, err
}
sourceID := payload.SourceID
if sourceID == 0 {
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
Name: gittypes.RepoName(gitConfig.URL),
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: gitConfig.URL,
Authentication: gitConfig.Authentication,
TLSSkipVerify: gitConfig.TLSSkipVerify,
},
})
if err != nil {
return nil, err
}
sourceID = src.ID
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
Name: gittypes.RepoName(gitConfig.URL),
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: gitConfig.URL,
Authentication: gitConfig.Authentication,
TLSSkipVerify: gitConfig.TLSSkipVerify,
},
})
if err != nil {
return nil, err
}
customTemplate.Artifact = &portainer.Artifact{
Files: []portainer.ArtifactFile{{
SourceID: sourceID,
SourceID: src.ID,
Path: gitConfig.ConfigFilePath,
Ref: gitConfig.ReferenceName,
Hash: commitHash,

View File

@@ -12,7 +12,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/gorilla/mux"
@@ -1036,69 +1035,3 @@ func TestCustomTemplateCreate_FromRepository_Validation_InvalidType(t *testing.T
require.NotNil(t, herr)
require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
}
func TestCustomTemplateCreate_FromRepository_WithSourceID_Success(t *testing.T) {
t.Parallel()
handler, ds, _ := newTestHandler(t)
handler.GitService = &gitServiceCreatingFile{}
var srcID portainer.SourceID
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{
Name: "example/repo",
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
return nil
}))
payload := customTemplateFromGitRepositoryPayload{
Title: "Source Template",
Description: "Created from source ID",
SourceID: srcID,
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
}
r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole)
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
require.Nil(t, herr)
require.Equal(t, http.StatusOK, rr.Code)
var tmpl portainer.CustomTemplate
require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
require.NotNil(t, tmpl.Artifact)
require.Len(t, tmpl.Artifact.Files, 1)
require.Equal(t, srcID, tmpl.Artifact.Files[0].SourceID)
require.Equal(t, "deadbeef123", tmpl.Artifact.Files[0].Hash)
}
func TestCustomTemplateCreate_FromRepository_WithSourceID_NonExistentSource(t *testing.T) {
t.Parallel()
handler, _, _ := newTestHandler(t)
handler.GitService = &gitServiceCreatingFile{}
payload := customTemplateFromGitRepositoryPayload{
Title: "Source Template",
Description: "Created from non-existent source ID",
SourceID: 999,
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
}
r := createTemplateRequest(t, "repository", payload, 1, portainer.AdministratorRole)
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusInternalServerError, herr.StatusCode)
}

View File

@@ -51,7 +51,6 @@ func (handler *Handler) customTemplateDelete(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
}
customTemplate.ResourceControl = resourceControl
access := userCanEditTemplate(customTemplate, securityContext)
if !access {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)

View File

@@ -1,296 +0,0 @@
package customtemplates
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/gorilla/mux"
"github.com/stretchr/testify/require"
)
func TestCustomTemplateDelete_NotFound(t *testing.T) {
t.Parallel()
handler, _, _ := newTestHandler(t)
r := httptest.NewRequest(http.MethodDelete, "/custom_templates/99", nil)
r = mux.SetURLVars(r, map[string]string{"id": "99"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}))
rr := httptest.NewRecorder()
herr := handler.customTemplateDelete(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusNotFound, herr.StatusCode)
}
func TestCustomTemplateDelete_Forbidden(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
CreatedByUserID: 1,
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
// User 2 did not create this template and is not an admin
r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2}))
rr := httptest.NewRecorder()
herr := handler.customTemplateDelete(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusForbidden, herr.StatusCode)
}
func TestCustomTemplateDelete_CreatorDeniedWhenAdminOnly(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
CreatedByUserID: 2,
})
require.NoError(t, err)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 1,
ResourceID: "1",
Type: portainer.CustomTemplateResourceControl,
AdministratorsOnly: true,
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
// User 2 created the template but an admin later changed it to admins-only
r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2}))
rr := httptest.NewRecorder()
herr := handler.customTemplateDelete(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusForbidden, herr.StatusCode)
}
func TestCustomTemplateDelete_CreatorDeniedWithoutResourceControl(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
CreatedByUserID: 2,
})
})
require.NoError(t, err)
// User 2 created this template but there is no resource control
r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2}))
rr := httptest.NewRecorder()
herr := handler.customTemplateDelete(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusForbidden, herr.StatusCode)
}
func TestCustomTemplateDelete_Success(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
CreatedByUserID: 2,
})
require.NoError(t, err)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 1,
ResourceID: "1",
Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2}))
rr := httptest.NewRecorder()
herr := handler.customTemplateDelete(rr, r)
require.Nil(t, herr)
require.Equal(t, http.StatusNoContent, rr.Code)
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
_, err := tx.CustomTemplate().Read(1)
require.True(t, tx.IsErrObjectNotFound(err))
return nil
})
require.NoError(t, err)
}
func TestCustomTemplateDelete_AdminCanDeleteAdminOnly(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
CreatedByUserID: 2,
})
require.NoError(t, err)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 1,
ResourceID: "1",
Type: portainer.CustomTemplateResourceControl,
AdministratorsOnly: true,
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}))
rr := httptest.NewRecorder()
herr := handler.customTemplateDelete(rr, r)
require.Nil(t, herr)
require.Equal(t, http.StatusNoContent, rr.Code)
}
func TestCustomTemplateDelete_PublicTemplateAllowsAnyUser(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
CreatedByUserID: 1,
})
require.NoError(t, err)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 1,
ResourceID: "1",
Type: portainer.CustomTemplateResourceControl,
Public: true,
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
// User 2 is not the creator but the template is public
r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2}))
rr := httptest.NewRecorder()
herr := handler.customTemplateDelete(rr, r)
require.Nil(t, herr)
require.Equal(t, http.StatusNoContent, rr.Code)
}
func TestCustomTemplateDelete_NonCreatorForbiddenWithPrivateRC(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
CreatedByUserID: 1,
})
require.NoError(t, err)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 1,
ResourceID: "1",
Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 1}},
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
// User 2 is not the creator and the template has a private resource control
r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2}))
rr := httptest.NewRecorder()
herr := handler.customTemplateDelete(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusForbidden, herr.StatusCode)
}
func TestCustomTemplateDelete_CreatorDeniedWithoutAccess(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
CreatedByUserID: 2,
})
require.NoError(t, err)
// RC exists but only grants access to user 3, not the creator (user 2)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 1,
ResourceID: "1",
Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 3}},
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
r := httptest.NewRequest(http.MethodDelete, "/custom_templates/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2}))
rr := httptest.NewRecorder()
herr := handler.customTemplateDelete(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusForbidden, herr.StatusCode)
}

View File

@@ -59,11 +59,12 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
customTemplate.ResourceControl = resourceControl
canEdit := userCanEditTemplate(customTemplate, securityContext)
hasAccess := false
if resourceControl != nil {
customTemplate.ResourceControl = resourceControl
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
return m.TeamID
})

View File

@@ -101,45 +101,6 @@ func TestCustomTemplateFile(t *testing.T) {
})
}
func TestCustomTemplateFile_CreatorDeniedWhenAdminOnly(t *testing.T) {
t.Parallel()
handler, store, fs := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
path, err := fs.StoreCustomTemplateFileFromBytes("5", "entrypoint", []byte("content"))
require.NoError(t, err)
err = tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 5,
EntryPoint: "entrypoint",
ProjectPath: path,
CreatedByUserID: 2,
})
require.NoError(t, err)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 5,
ResourceID: "5",
Type: portainer.CustomTemplateResourceControl,
AdministratorsOnly: true,
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
r := httptest.NewRequest(http.MethodGet, "/custom_templates/5/file", nil)
r = mux.SetURLVars(r, map[string]string{"id": "5"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2}))
rr := httptest.NewRecorder()
herr := handler.customTemplateFile(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusForbidden, herr.StatusCode)
}
func TestCustomTemplateFile_GitTemplate(t *testing.T) {
t.Parallel()

View File

@@ -54,16 +54,18 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
customTemplate.ResourceControl = resourceControl
canEdit := userCanEditTemplate(customTemplate, securityContext)
hasAccess := false
if resourceControl != nil {
customTemplate.ResourceControl = resourceControl
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
return m.TeamID
})
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
}
if !canEdit && !hasAccess {

View File

@@ -126,40 +126,6 @@ func TestInspectHandler(t *testing.T) {
})
}
func TestInspectHandler_CreatorDeniedWhenAdminOnly(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 5,
CreatedByUserID: 2,
})
require.NoError(t, err)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 5,
ResourceID: "5",
Type: portainer.CustomTemplateResourceControl,
AdministratorsOnly: true,
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
r := httptest.NewRequest(http.MethodGet, "/custom_templates/5", nil)
r = mux.SetURLVars(r, map[string]string{"id": "5"})
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 2}))
rr := httptest.NewRecorder()
herr := handler.customTemplateInspect(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusForbidden, herr.StatusCode)
}
func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) {
t.Parallel()
@@ -176,7 +142,6 @@ func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) {
}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
return tx.CustomTemplate().Create(&portainer.CustomTemplate{

View File

@@ -12,7 +12,6 @@ import (
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/gitops/workflows"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
@@ -37,26 +36,28 @@ type customTemplateUpdatePayload struct {
Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"`
// Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes)
Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
// SourceID references an existing Source for git credentials/URL.
// When set, the inline URL and authentication fields are ignored.
SourceID portainer.SourceID `example:"1"`
// Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
RepositoryURL string `example:"https://github.com/openfaas/faas"`
// URL of a Git repository hosting the Stack file
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
// Reference name of a Git repository hosting the Stack file
RepositoryReferenceName string `example:"refs/heads/master"`
// Deprecated: use SourceID instead. Use authentication to clone the Git repository.
// Use authentication to clone the Git repository
RepositoryAuthentication bool `example:"true"`
// Deprecated: use SourceID instead. Username used in basic authentication. Required when RepositoryAuthentication is true.
// Username used in basic authentication. Required when RepositoryAuthentication is true
// and RepositoryGitCredentialID is 0. Ignored if RepositoryAuthType is token
RepositoryUsername string `example:"myGitUsername"`
// Deprecated: use SourceID instead. Password used in basic authentication or token used in token authentication. Required when RepositoryAuthentication is true.
// Password used in basic authentication or token used in token authentication.
// Required when RepositoryAuthentication is true and RepositoryGitCredentialID is 0
RepositoryPassword string `example:"myGitPassword"`
// GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
// is true and RepositoryUsername/RepositoryPassword are not provided
RepositoryGitCredentialID int `example:"0"`
// Path to the Stack file inside the Git repository
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
// Content of stack file
FileContent string `validate:"required"`
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
// Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
IsComposeFormat bool `example:"false"`
@@ -69,6 +70,10 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
return errors.New("Invalid custom template title")
}
if len(payload.FileContent) == 0 && len(payload.RepositoryURL) == 0 {
return errors.New("Either file content or git repository url need to be provided")
}
if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
return errors.New("Invalid custom template platform")
}
@@ -85,19 +90,8 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
return errors.New("Invalid note. <img> tag is not supported")
}
if len(payload.FileContent) == 0 && payload.SourceID == 0 {
if len(payload.RepositoryURL) == 0 {
return errors.New("Either file content, git repository url, or source ID need to be provided")
}
if !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
}
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
}
if len(payload.ComposeFilePathInRepository) == 0 {
@@ -139,15 +133,15 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
return httperror.BadRequest("Invalid request payload", err)
}
duplicates, err := handler.DataStore.CustomTemplate().ReadAll(func(t portainer.CustomTemplate) bool {
return t.ID != portainer.CustomTemplateID(customTemplateID) && t.Title == payload.Title
})
customTemplates, err := handler.DataStore.CustomTemplate().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
}
if len(duplicates) > 0 {
return httperror.InternalServerError("Template name must be unique", errors.New("Template name must be unique"))
for _, existingTemplate := range customTemplates {
if existingTemplate.ID != portainer.CustomTemplateID(customTemplateID) && existingTemplate.Title == payload.Title {
return httperror.InternalServerError("Template name must be unique", errors.New("Template name must be unique"))
}
}
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
@@ -162,13 +156,8 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
}
customTemplate.ResourceControl = resourceControl
if !userCanEditTemplate(customTemplate, securityContext) {
access := userCanEditTemplate(customTemplate, securityContext)
if !access {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
@@ -182,33 +171,35 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.IsComposeFormat = payload.IsComposeFormat
customTemplate.EdgeTemplate = payload.EdgeTemplate
if payload.SourceID != 0 || payload.RepositoryURL != "" {
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{
SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.ComposeFilePathInRepository,
RepositoryURL: payload.RepositoryURL,
TLSSkipVerify: payload.TLSSkipVerify,
RepositoryAuthentication: payload.RepositoryAuthentication,
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
})
if httpErr != nil {
return httpErr
if payload.RepositoryURL != "" {
if !validate.IsURL(payload.RepositoryURL) {
return httperror.BadRequest("Invalid repository URL. Must correspond to a valid URL format", err)
}
var username, password string
if gitConfig.Authentication != nil {
username = gitConfig.Authentication.Username
password = gitConfig.Authentication.Password
gitConfig := &gittypes.RepoConfig{
URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.ComposeFilePathInRepository,
TLSSkipVerify: payload.TLSSkipVerify,
}
repositoryUsername := ""
repositoryPassword := ""
if payload.RepositoryAuthentication {
repositoryUsername = payload.RepositoryUsername
repositoryPassword = payload.RepositoryPassword
gitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
}
}
cleanBackup, err := git.CloneWithBackup(context.TODO(), handler.GitService, handler.FileService, git.CloneOptions{
ProjectPath: customTemplate.ProjectPath,
URL: gitConfig.URL,
ReferenceName: gitConfig.ReferenceName,
Username: username,
Password: password,
Username: repositoryUsername,
Password: repositoryPassword,
TLSSkipVerify: gitConfig.TLSSkipVerify,
})
if err != nil {
@@ -221,34 +212,30 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
context.TODO(),
gitConfig.URL,
gitConfig.ReferenceName,
username,
password,
repositoryUsername,
repositoryPassword,
gitConfig.TLSSkipVerify,
)
if err != nil {
return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err))
}
sourceID := payload.SourceID
if sourceID == 0 {
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
Name: gittypes.RepoName(gitConfig.URL),
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: gitConfig.URL,
Authentication: gitConfig.Authentication,
TLSSkipVerify: gitConfig.TLSSkipVerify,
},
})
if err != nil {
return httperror.InternalServerError("Unable to find or create git source", err)
}
sourceID = src.ID
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
Name: gittypes.RepoName(gitConfig.URL),
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: gitConfig.URL,
Authentication: gitConfig.Authentication,
TLSSkipVerify: gitConfig.TLSSkipVerify,
},
})
if err != nil {
return httperror.InternalServerError("Unable to find or create git source", err)
}
customTemplate.Artifact = &portainer.Artifact{
Files: []portainer.ArtifactFile{{
SourceID: sourceID,
SourceID: src.ID,
Path: gitConfig.ConfigFilePath,
Ref: gitConfig.ReferenceName,
Hash: commitHash,

View File

@@ -9,7 +9,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/gorilla/mux"
@@ -163,6 +162,43 @@ func TestCustomTemplateUpdate_Success_FileContent(t *testing.T) {
require.NoError(t, err)
}
func TestCustomTemplateUpdate_OwnerCanUpdate(t *testing.T) {
t.Parallel()
handler, ds, _ := newTestHandler(t)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
Title: "User Template",
EntryPoint: filesystem.ComposeFileDefaultName,
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
CreatedByUserID: 2,
})
}))
payload := customTemplateUpdatePayload{
Title: "User Template Updated",
Description: "Updated by owner",
FileContent: "version: '3'",
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
}
// User 2 is the creator, not an admin
r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 2})
rr := httptest.NewRecorder()
herr := handler.customTemplateUpdate(rr, r)
require.Nil(t, herr)
require.Equal(t, http.StatusOK, rr.Code)
var tmpl portainer.CustomTemplate
require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
require.Equal(t, "User Template Updated", tmpl.Title)
}
func TestCustomTemplateUpdate_SameTitleAllowed(t *testing.T) {
t.Parallel()
@@ -405,183 +441,6 @@ func TestCustomTemplateUpdate_ClearsArtifact(t *testing.T) {
require.NoError(t, err)
}
func TestCustomTemplateUpdate_CreatorDeniedWhenAdminOnly(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
Title: "User Template",
EntryPoint: filesystem.ComposeFileDefaultName,
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
CreatedByUserID: 2,
})
require.NoError(t, err)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 1,
ResourceID: "1",
Type: portainer.CustomTemplateResourceControl,
AdministratorsOnly: true,
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
payload := customTemplateUpdatePayload{
Title: "User Template Updated",
Description: "Attempted update by creator after adminonly change",
FileContent: "version: '3'",
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
}
r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 2})
rr := httptest.NewRecorder()
herr := handler.customTemplateUpdate(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusForbidden, herr.StatusCode)
}
func TestCustomTemplateUpdate_WithSourceID_Success(t *testing.T) {
t.Parallel()
handler, ds, _ := newTestHandler(t)
handler.GitService = &gitServiceCreatingFile{}
projectDir := t.TempDir()
var srcID portainer.SourceID
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
Title: "Source Template",
EntryPoint: filesystem.ComposeFileDefaultName,
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
CreatedByUserID: 1,
ProjectPath: projectDir,
}))
src := &portainer.Source{
Name: "example/repo",
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
return nil
}))
payload := customTemplateUpdatePayload{
Title: "Source Template",
Description: "Updated via source ID",
SourceID: srcID,
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
}
r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
rr := httptest.NewRecorder()
herr := handler.customTemplateUpdate(rr, r)
require.Nil(t, herr)
require.Equal(t, http.StatusOK, rr.Code)
var tmpl portainer.CustomTemplate
require.NoError(t, json.NewDecoder(rr.Body).Decode(&tmpl))
require.NotNil(t, tmpl.Artifact)
require.Len(t, tmpl.Artifact.Files, 1)
require.Equal(t, srcID, tmpl.Artifact.Files[0].SourceID)
require.Equal(t, "deadbeef123", tmpl.Artifact.Files[0].Hash)
}
func TestCustomTemplateUpdate_WithSourceID_NonExistentSource(t *testing.T) {
t.Parallel()
handler, ds, _ := newTestHandler(t)
handler.GitService = &gitServiceCreatingFile{}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
Title: "Source Template",
EntryPoint: filesystem.ComposeFileDefaultName,
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
CreatedByUserID: 1,
})
}))
payload := customTemplateUpdatePayload{
Title: "Source Template",
Description: "Updated via non-existent source ID",
SourceID: 999,
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
}
r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
rr := httptest.NewRecorder()
herr := handler.customTemplateUpdate(rr, r)
require.NotNil(t, herr)
require.Equal(t, http.StatusNotFound, herr.StatusCode)
}
func TestCustomTemplateUpdate_AdminCanUpdateAdminOnly(t *testing.T) {
t.Parallel()
handler, store, _ := newTestHandler(t)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.CustomTemplate().Create(&portainer.CustomTemplate{
ID: 1,
Title: "User Template",
EntryPoint: filesystem.ComposeFileDefaultName,
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
CreatedByUserID: 2,
})
require.NoError(t, err)
err = tx.ResourceControl().Create(&portainer.ResourceControl{
ID: 1,
ResourceID: "1",
Type: portainer.CustomTemplateResourceControl,
AdministratorsOnly: true,
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
payload := customTemplateUpdatePayload{
Title: "Updated by Admin",
Description: "Admin update of adminonly template",
FileContent: "version: '3'",
Type: portainer.DockerComposeStack,
Platform: portainer.CustomTemplatePlatformLinux,
}
r := updateTemplateRequest(t, "1", payload, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
rr := httptest.NewRecorder()
herr := handler.customTemplateUpdate(rr, r)
require.Nil(t, herr)
require.Equal(t, http.StatusOK, rr.Code)
}
func TestCustomTemplateUpdate_GitRepository_Success(t *testing.T) {
t.Parallel()

View File

@@ -4,14 +4,11 @@ import (
"net/http"
"sync"
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
)
// Handler is the HTTP handler used to handle environment(endpoint) group operations.
@@ -51,27 +48,5 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
}
func userCanEditTemplate(customTemplate *portainer.CustomTemplate, securityContext *security.RestrictedRequestContext) bool {
resourceControl := customTemplate.ResourceControl
if securityContext.IsAdmin {
return true
}
if resourceControl == nil || resourceControl.AdministratorsOnly {
return false
}
if resourceControl.Public {
return true
}
if customTemplate.CreatedByUserID != securityContext.UserID {
return false
}
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
return m.TeamID
})
return authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
return securityContext.IsAdmin || customTemplate.CreatedByUserID == securityContext.UserID
}

View File

@@ -1,95 +0,0 @@
package docker
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/datastore"
dockerdomain "github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// unreachableDockerURL points the test environment at a port that refuses connections, so an
// authorized caller fails fast when the handler builds a docker client rather than blocking on a
// real daemon. The authorization middleware runs before this, which is what these tests assert.
const unreachableDockerURL = "tcp://127.0.0.1:1"
func newDashboardAuthTestHandler(t *testing.T) (*Handler, *jwt.Service, *datastore.Store) {
t.Helper()
fips.InitFIPS(false)
_, store := datastore.MustNewTestStore(t, true, true)
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
ID: 1, Name: "docker-env", Type: portainer.DockerEnvironment, URL: unreachableDockerURL,
}))
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err)
bouncer := security.NewRequestBouncer(t.Context(), store, jwtService, apikey.NewAPIKeyService(nil, nil))
factory := dockerclient.NewClientFactory(nil, nil)
authorizationService := authorization.NewService(store)
containerService := dockerdomain.NewContainerService(factory, store)
handler := NewHandler(bouncer, authorizationService, store, factory, containerService)
return handler, jwtService, store
}
func dashboardRequest(t *testing.T, handler *Handler, jwtService *jwt.Service, user *portainer.User) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/docker/1/dashboard", nil)
tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
require.NoError(t, err)
testhelpers.AddTestSecurityCookie(req, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
return rr
}
// TestEndpointAuthorization_DeniedUser_Returns403 verifies that the docker /dashboard
// route rejects users with no access policy for the target environment (R8S-1057).
func TestEndpointAuthorization_DeniedUser_Returns403(t *testing.T) {
handler, jwtService, store := newDashboardAuthTestHandler(t)
noAccessUser := &portainer.User{
Username: "no-access",
Role: portainer.StandardUserRole,
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
}
require.NoError(t, store.User().Create(noAccessUser))
// A standard user with no access policy must be rejected before the dashboard handler
// builds a docker client for the environment.
rr := dashboardRequest(t, handler, jwtService, noAccessUser)
assert.Equal(t, http.StatusForbidden, rr.Code)
}
// TestEndpointAuthorization_AuthorizedUser_NotForbidden verifies that the docker /dashboard
// route lets an authorized caller through the authorization middleware (R8S-1057). The request
// fails later when the handler cannot reach the docker daemon, but it must not be rejected with 403.
func TestEndpointAuthorization_AuthorizedUser_NotForbidden(t *testing.T) {
handler, jwtService, store := newDashboardAuthTestHandler(t)
adminUser := &portainer.User{Username: "admin", Role: portainer.AdministratorRole}
require.NoError(t, store.User().Create(adminUser))
rr := dashboardRequest(t, handler, jwtService, adminUser)
assert.NotEqual(t, http.StatusForbidden, rr.Code)
}

View File

@@ -44,9 +44,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Use(bouncer.AuthenticatedAccess)
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"), dockerOnlyMiddleware)
// /dashboard is the only route on this router without its own endpoint authorization;
// the containers/images sub-routers already apply CheckEndpointAuthorization.
endpointRouter.Handle("/dashboard", middlewares.CheckEndpointAuthorization(bouncer)(httperror.LoggerHandler(h.dashboard))).Methods(http.MethodGet)
endpointRouter.Handle("/dashboard", httperror.LoggerHandler(h.dashboard)).Methods(http.MethodGet)
containersHandler := containers.NewHandler("/docker/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
endpointRouter.PathPrefix("/containers").Handler(containersHandler)

View File

@@ -9,12 +9,10 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/portainer/portainer/pkg/validate"
"github.com/pkg/errors"
@@ -27,18 +25,15 @@ type edgeStackFromGitRepositoryPayload struct {
// Name must start with a lowercase character or number
// Example: stack-name or stack_123 or stackName
Name string `example:"stack-name" validate:"required"`
// SourceID references an existing Source for git credentials/URL.
// When set, the inline URL and authentication fields are ignored.
SourceID portainer.SourceID `example:"1"`
// Deprecated: Use SourceID instead. URL of a Git repository hosting the Stack file.
RepositoryURL string `example:"https://github.com/openfaas/faas"`
// URL of a Git repository hosting the Stack file
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
// Reference name of a Git repository hosting the Stack file
RepositoryReferenceName string `example:"refs/heads/master"`
// Deprecated: Use SourceID instead. Use basic authentication to clone the Git repository.
// Use basic authentication to clone the Git repository
RepositoryAuthentication bool `example:"true"`
// Deprecated: Use SourceID instead. Username used in basic authentication.
// Username used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryUsername string `example:"myGitUsername"`
// Deprecated: Use SourceID instead. Password used in basic authentication.
// Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string `example:"myGitPassword"`
// Path to the Stack file inside the Git repository
FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
@@ -53,7 +48,7 @@ type edgeStackFromGitRepositoryPayload struct {
Registries []portainer.RegistryID
// Uses the manifest's namespaces instead of the default one
UseManifestNamespaces bool
// Deprecated: Use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
}
@@ -66,13 +61,12 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
}
if payload.SourceID == 0 {
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password must be specified when authentication is enabled")
}
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password must be specified when authentication is enabled")
}
if payload.DeploymentType != portainer.EdgeStackDeploymentCompose && payload.DeploymentType != portainer.EdgeStackDeploymentKubernetes {
@@ -124,22 +118,18 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
return stack, nil
}
repoConfig, httpErr := sources.ResolveRepoConfig(tx, sources.RepoConfigInput{
SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.FilePathInRepository,
RepositoryURL: payload.RepositoryURL,
TLSSkipVerify: payload.TLSSkipVerify,
RepositoryAuthentication: payload.RepositoryAuthentication,
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
})
if httpErr != nil {
return nil, httpErr
repoConfig := gittypes.RepoConfig{
URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.FilePathInRepository,
TLSSkipVerify: payload.TLSSkipVerify,
}
if err := ssrf.CheckURL(r.Context(), repoConfig.URL); err != nil {
return nil, errors.Wrap(err, "repository URL blocked by SSRF policy")
if payload.RepositoryAuthentication {
repoConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
}
}
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)

View File

@@ -135,10 +135,6 @@ func (handler *Handler) parseHeaders(r *http.Request, endpoint *portainer.Endpoi
version := r.Header.Get(portainer.PortainerAgentHeader)
endpoint.Agent.Version = version
if gpuOperatorHeader := r.Header.Get(portainer.HTTPResponseAgentGPUOperator); gpuOperatorHeader != "" {
endpoint.Kubernetes.Flags.GPUOperator = gpuOperatorHeader == "true"
}
return nil
}

View File

@@ -93,7 +93,7 @@ func (handler *Handler) endpointSummaryCounts(w http.ResponseWriter, r *http.Req
// write LastCheckInDate back to the database, so the tx value grows stale.
// The tx path cannot access the in-memory map; this non-tx access is
// intentional.
endpointSvc := handler.DataStore.Endpoint()
endpointSvc := handler.DataStore.Endpoint() //nolint:forbidigo
for i := range endpoints {
if t, ok := endpointSvc.Heartbeat(endpoints[i].ID); ok {
endpoints[i].LastCheckInDate = t

View File

@@ -6,13 +6,10 @@ import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/portainer/portainer/pkg/validate"
"github.com/rs/zerolog/log"
)
@@ -22,32 +19,19 @@ type fileResponse struct {
}
type repositoryFilePreviewPayload struct {
// SourceID resolves URL and auth from the stored Source record.
// When set, the inline Repository/Username/Password/TLSSkipVerify fields are ignored.
SourceID portainer.SourceID `json:"sourceID" example:"1"`
Reference string `json:"reference" example:"refs/heads/master"`
Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"`
Reference string `json:"reference" example:"refs/heads/master"`
Username string `json:"username" example:"myGitUsername"`
Password string `json:"password" example:"myGitPassword"`
// Path to file whose content will be read
TargetFile string `json:"targetFile" example:"docker-compose.yml"`
// URL of a Git repository to preview.
// Deprecated: use SourceID instead
Repository string `json:"repository" example:"https://github.com/openfaas/faas"`
// Username for git authentication.
// Deprecated: use SourceID instead
Username string `json:"username" example:"myGitUsername"`
// Password for git authentication.
// Deprecated: use SourceID instead
Password string `json:"password" example:"myGitPassword"`
// TLSSkipVerify skips SSL verification when cloning the Git repository.
// Deprecated: use SourceID instead
TLSSkipVerify bool `json:"tlsSkipVerify" example:"false"`
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
}
func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error {
if payload.SourceID == 0 {
if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) {
return errors.New("invalid repository URL. Must correspond to a valid URL format")
}
if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) {
return errors.New("invalid repository URL. Must correspond to a valid URL format")
}
if len(payload.Reference) == 0 {
@@ -72,7 +56,6 @@ func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error {
// @param body body repositoryFilePreviewPayload true "Template details"
// @success 200 {object} fileResponse "Success"
// @failure 400 "Invalid request"
// @failure 404 "Source not found"
// @failure 500 "Server error"
// @router /gitops/repo/file/preview [post]
func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -82,29 +65,6 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
return httperror.BadRequest("Invalid request payload", err)
}
repoURL := payload.Repository
username := payload.Username
password := payload.Password
tlsSkipVerify := payload.TLSSkipVerify
if payload.SourceID != 0 {
src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, payload.SourceID)
if httpErr != nil {
return httpErr
}
repoURL = src.Git.URL
if src.Git.Authentication != nil {
username = src.Git.Authentication.Username
password = src.Git.Authentication.Password
}
tlsSkipVerify = src.Git.TLSSkipVerify
}
if err := ssrf.CheckURL(r.Context(), repoURL); err != nil {
return httperror.BadRequest("Repository URL blocked by SSRF policy", err)
}
projectPath, err := handler.fileService.GetTemporaryPath()
if err != nil {
return httperror.InternalServerError("Unable to create temporary folder", err)
@@ -113,11 +73,11 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
err = handler.gitService.CloneRepository(
context.TODO(),
projectPath,
repoURL,
payload.Repository,
payload.Reference,
username,
password,
tlsSkipVerify,
payload.Username,
payload.Password,
payload.TLSSkipVerify,
)
if err != nil {
if errors.Is(err, gittypes.ErrAuthenticationFailure) {

View File

@@ -8,17 +8,17 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/validate"
)
// GitAuthenticationPayload holds authentication parameters for a git source
type GitAuthenticationPayload struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
Provider gittypes.GitProvider `json:"provider"`
AuthorizationType gittypes.GitCredentialAuthType `json:"authorizationType"`
}
// GitSourceCreatePayload holds the parameters for creating a git-backed source
@@ -31,17 +31,45 @@ type GitSourceCreatePayload struct {
// Validate implements the portainer.Validatable interface
func (payload *GitSourceCreatePayload) Validate(_ *http.Request) error {
if !validate.IsURL(payload.URL) {
return errors.New("invalid repository URL. Must correspond to a valid URL format")
if strings.TrimSpace(payload.URL) == "" {
return errors.New("url is required")
}
return nil
}
// BuildGitSource constructs a portainer.Source from a GitSourceCreatePayload
func BuildGitSource(payload GitSourceCreatePayload) *portainer.Source {
gitConfig := &gittypes.RepoConfig{
URL: payload.URL,
TLSSkipVerify: payload.TLSSkipVerify,
}
if payload.Authentication != nil {
gitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.Authentication.Username,
Password: payload.Authentication.Password,
Provider: payload.Authentication.Provider,
AuthorizationType: payload.Authentication.AuthorizationType,
}
}
name := payload.Name
if strings.TrimSpace(name) == "" {
name = gittypes.RepoName(payload.URL)
}
return &portainer.Source{
Name: name,
Type: portainer.SourceTypeGit,
Git: gitConfig,
}
}
// @id GitOpsSourcesCreateGit
// @summary Create a Git source
// @description Creates a new GitOps source backed by a Git repository.
// @description **Access policy**: administrator
// @description **Access policy**: admin
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -51,7 +79,6 @@ func (payload *GitSourceCreatePayload) Validate(_ *http.Request) error {
// @success 201 {object} portainer.Source
// @failure 400 "Invalid request payload"
// @failure 403 "Access denied"
// @failure 409 "A source with this URL and credentials already exists"
// @failure 500 "Server error"
// @router /gitops/sources/git [post]
func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -61,28 +88,11 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe
return httperror.BadRequest("Invalid request payload", err)
}
src, err := BuildGitSource(payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
username, password := "", ""
if payload.Authentication != nil {
username = payload.Authentication.Username
password = payload.Authentication.Password
}
src := BuildGitSource(payload)
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if isUnique, err := workflows.ValidateUniqueSource(tx, payload.URL, username, password, 0); err != nil {
return err
} else if !isUnique {
return ErrDuplicateSource
}
return tx.Source().Create(src)
}); errors.Is(err, ErrDuplicateSource) {
return httperror.Conflict("A source with this URL and credentials already exists", err)
} else if err != nil {
}); err != nil {
return httperror.InternalServerError("Unable to create source", err)
}
@@ -90,42 +100,3 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe
return response.JSONWithStatus(w, src, http.StatusCreated)
}
// BuildGitSource constructs a portainer.Source from a GitSourceCreatePayload
func BuildGitSource(payload GitSourceCreatePayload) (*portainer.Source, error) {
src := BuildBaseGitSource(payload)
src.Git.Authentication = BuildAuth(payload.Authentication)
return src, nil
}
// BuildBaseGitSource constructs the source skeleton (name, URL, TLS) without
// authentication.
func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source {
name := payload.Name
if strings.TrimSpace(name) == "" {
name = gittypes.RepoName(payload.URL)
}
return &portainer.Source{
Name: name,
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: payload.URL,
TLSSkipVerify: payload.TLSSkipVerify,
},
}
}
// BuildAuth constructs basic git authentication from the payload, returning nil
// when no authentication is provided.
func BuildAuth(payload *GitAuthenticationPayload) *gittypes.GitAuthentication {
if payload == nil {
return nil
}
return &gittypes.GitAuthentication{
Username: payload.Username,
Password: payload.Password,
}
}

View File

@@ -16,10 +16,9 @@ import (
func TestBuildGitSource_DerivesNameFromURL(t *testing.T) {
t.Parallel()
src, err := BuildGitSource(GitSourceCreatePayload{
src := BuildGitSource(GitSourceCreatePayload{
URL: "https://github.com/org/my-repo.git",
})
require.NoError(t, err)
require.Equal(t, "my-repo", src.Name)
require.Equal(t, portainer.SourceTypeGit, src.Type)
@@ -29,11 +28,10 @@ func TestBuildGitSource_DerivesNameFromURL(t *testing.T) {
func TestBuildGitSource_UsesExplicitName(t *testing.T) {
t.Parallel()
src, err := BuildGitSource(GitSourceCreatePayload{
src := BuildGitSource(GitSourceCreatePayload{
Name: "custom-name",
URL: "https://github.com/org/repo.git",
})
require.NoError(t, err)
require.Equal(t, "custom-name", src.Name)
}
@@ -41,14 +39,13 @@ func TestBuildGitSource_UsesExplicitName(t *testing.T) {
func TestBuildGitSource_WithAuthentication(t *testing.T) {
t.Parallel()
src, err := BuildGitSource(GitSourceCreatePayload{
src := BuildGitSource(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "alice",
Password: "secret",
},
})
require.NoError(t, err)
require.NotNil(t, src.Git.Authentication)
require.Equal(t, "alice", src.Git.Authentication.Username)
@@ -152,128 +149,6 @@ func TestGitSourceCreate_MissingURL(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestGitSourceCreate_ConflictOnDuplicateURLAndCredentials(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
body, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "alice",
Password: "secret",
},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, body))
require.Equal(t, http.StatusCreated, rr.Code)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, body))
require.Equal(t, http.StatusConflict, rr.Code)
}
func TestGitSourceCreate_AllowsDuplicateURLWithDifferentCredentials(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
first, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "alice",
Password: "secret",
},
})
require.NoError(t, err)
second, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "bob",
Password: "other",
},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, first))
require.Equal(t, http.StatusCreated, rr.Code)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, second))
require.Equal(t, http.StatusCreated, rr.Code)
}
func TestGitSourceCreate_ConflictOnDuplicateAuthlessSource(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
body, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, body))
require.Equal(t, http.StatusCreated, rr.Code)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, body))
require.Equal(t, http.StatusConflict, rr.Code)
}
func TestGitSourceCreate_AllowsAuthlessAndAuthenticatedSameURL(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
authless, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
})
require.NoError(t, err)
authenticated, err := json.Marshal(GitSourceCreatePayload{
URL: "https://github.com/org/repo.git",
Authentication: &GitAuthenticationPayload{
Username: "alice",
Password: "secret",
},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, authless))
require.Equal(t, http.StatusCreated, rr.Code)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildCreateReq(t, 1, authenticated))
require.Equal(t, http.StatusCreated, rr.Code)
}
func TestGitSourceCreate_MalformedJSON(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)

View File

@@ -13,7 +13,8 @@ import (
)
type gitAuthInfo struct {
Username string `json:"username"`
Type gittypes.GitCredentialAuthType `json:"type"`
Username string `json:"username"`
}
type connectionInfo struct {
@@ -22,7 +23,7 @@ type connectionInfo struct {
Authentication *gitAuthInfo `json:"authentication,omitempty"`
}
type AutoUpdateInfo struct {
type autoUpdateInfo struct {
Mechanism string `json:"mechanism,omitempty"`
FetchInterval string `json:"fetchInterval,omitempty"`
}
@@ -31,14 +32,14 @@ type AutoUpdateInfo struct {
type SourceDetail struct {
Source
Connection connectionInfo `json:"connection" validate:"required"`
AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"`
AutoUpdate *autoUpdateInfo `json:"autoUpdate,omitempty"`
Workflows []workflows.Workflow `json:"workflows"`
}
// @id GitOpsSourceGet
// @summary Get a GitOps source by ID
// @description Returns a single GitOps source with its connection settings and linked workflows.
// @description **Access policy**: authenticated
// @description **Access policy**: admin
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -84,9 +85,9 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
}
func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow) SourceDetail {
var autoUpdate *AutoUpdateInfo
var autoUpdate *autoUpdateInfo
if len(sourceWfs) > 0 {
autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate)
autoUpdate = buildAutoUpdateInfo(sourceWfs[0].AutoUpdate)
}
return SourceDetail{
@@ -113,23 +114,24 @@ func buildGitAuthInfo(auth *gittypes.GitAuthentication) *gitAuthInfo {
return nil
}
return &gitAuthInfo{
Type: auth.AuthorizationType,
Username: auth.Username,
}
}
func BuildAutoUpdateInfo(autoUpdate *portainer.AutoUpdateSettings) *AutoUpdateInfo {
func buildAutoUpdateInfo(autoUpdate *portainer.AutoUpdateSettings) *autoUpdateInfo {
if autoUpdate == nil {
return nil
}
switch {
case autoUpdate.Interval != "":
return &AutoUpdateInfo{
return &autoUpdateInfo{
Mechanism: "Interval",
FetchInterval: autoUpdate.Interval,
}
case autoUpdate.Webhook != "":
return &AutoUpdateInfo{
return &autoUpdateInfo{
Mechanism: "Webhook",
}
default:

View File

@@ -41,12 +41,11 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
authenticatedRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
authenticatedRouter.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet)
authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet)
adminRouter := h.PathPrefix("/gitops/sources").Subrouter()
adminRouter.Use(bouncer.AdminAccess)
adminRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost)
adminRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost)
adminRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet)
adminRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete)
adminRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost)

View File

@@ -14,10 +14,10 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id GitOpsSourcesTestById
// @summary Test the connection of a stored source
// @id GitOpsSourcesTestGit
// @summary Test a Git source connection
// @description Tests connectivity for a GitOps source, applying optional overrides to the stored configuration.
// @description **Access policy**: administrator
// @description **Access policy**: admin
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -72,40 +72,6 @@ type ConnectionTestResult struct {
Error string `json:"error,omitempty"`
}
// @id GitOpsSourcesTest
// @summary Test a Git source connection
// @description Tests connectivity for Git connection details that have not been persisted yet.
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param body body GitSourceCreatePayload true "Git connection details"
// @success 200 {object} ConnectionTestResult "Connection test result"
// @failure 400 "Invalid request payload"
// @failure 403 "Access denied"
// @failure 500 "Server error"
// @router /gitops/sources/test [post]
func (h *Handler) gitSourceTest(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload GitSourceCreatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
src, err := BuildGitSource(payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
if src.Git == nil {
return httperror.InternalServerError("Source has no git configuration", nil)
}
result := testSourceConnection(r.Context(), h.gitService, src.Git)
return response.JSON(w, result)
}
// testSourceConnection verifies that a git repository is reachable with the given config.
func testSourceConnection(ctx context.Context, gitService portainer.GitService, config *gittypes.RepoConfig) ConnectionTestResult {
var username, password string

View File

@@ -4,20 +4,22 @@ import (
"fmt"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows"
)
// Source represents a unique git repository used as a GitOps source across one or more workflows.
type Source struct {
ID portainer.SourceID `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Type SourceType `json:"type" validate:"required"`
URL string `json:"url" validate:"required"`
Status workflows.Status `json:"status" validate:"required"`
Error string `json:"error,omitempty"`
UsedBy int `json:"usedBy"`
Environments int `json:"environments"`
LastSync int64 `json:"lastSync"`
ID portainer.SourceID `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Type SourceType `json:"type" validate:"required"`
URL string `json:"url" validate:"required"`
Status workflows.Status `json:"status" validate:"required"`
Error string `json:"error,omitempty"`
Provider gittypes.GitProvider `json:"provider,omitempty"`
UsedBy int `json:"usedBy"`
Environments int `json:"environments"`
LastSync int64 `json:"lastSync"`
}
type SourceType string

View File

@@ -12,12 +12,11 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/validate"
)
var (
ErrNotGitSource = errors.New("source is not a Git source")
ErrDuplicateSource = errors.New("a source with this URL and credentials already exists")
ErrNotGitSource = errors.New("source is not a Git source")
ErrDuplicateSourceURL = errors.New("a source with this URL already exists")
)
// GitSourceUpdatePayload holds the parameters for creating a git-backed source
@@ -30,23 +29,21 @@ type GitSourceUpdatePayload struct {
}
type GitAuthenticationUpdatePayload struct {
Username *string `json:"username"`
Password *string `json:"password"`
Username *string `json:"username"`
Password *string `json:"password"`
Provider *gittypes.GitProvider `json:"provider" swaggertype:"integer" enums:"0,1,2,3,4,5,6"`
AuthorizationType *gittypes.GitCredentialAuthType `json:"authorizationType" swaggertype:"integer" enums:"0,1"`
}
// Validate implements the portainer.Validatable interface
func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error {
if payload.URL != nil && !validate.IsURL(*payload.URL) {
return errors.New("invalid repository URL. Must correspond to a valid URL format")
}
return nil
}
// @id GitOpsSourcesUpdateGit
// @summary Update a Git source
// @description Updates an existing GitOps source backed by a Git repository.
// @description **Access policy**: administrator
// @description **Access policy**: admin
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -58,7 +55,7 @@ func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error {
// @failure 400 "Invalid request payload"
// @failure 403 "Access denied"
// @failure 404 "Source not found"
// @failure 409 "A source with this URL and credentials already exists"
// @failure 409 "A source with this URL already exists"
// @failure 500 "Server error"
// @router /gitops/sources/{id} [put]
func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -80,6 +77,14 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
if payload.URL != nil {
if isUnique, err := workflows.ValidateUniqueSourceURL(tx, *payload.URL, sourceID); err != nil {
return err
} else if !isUnique {
return ErrDuplicateSourceURL
}
}
if src, err = tx.Source().Read(sourceID); err != nil {
return err
}
@@ -88,25 +93,13 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
return err
}
username, password := "", ""
if src.Git != nil && src.Git.Authentication != nil {
username = src.Git.Authentication.Username
password = src.Git.Authentication.Password
}
if isUnique, err := workflows.ValidateUniqueSource(tx, src.Git.URL, username, password, sourceID); err != nil {
return err
} else if !isUnique {
return ErrDuplicateSource
}
return tx.Source().Update(src.ID, src)
}); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if errors.Is(err, ErrNotGitSource) {
return httperror.BadRequest("Source is not a Git source", err)
} else if errors.Is(err, ErrDuplicateSource) {
return httperror.Conflict("A source with this URL and credentials already exists", err)
} else if errors.Is(err, ErrDuplicateSourceURL) {
return httperror.Conflict("A source with this URL already exists", err)
} else if err != nil {
return httperror.InternalServerError("Unable to update source", err)
}
@@ -118,27 +111,6 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
// ApplyGitSourceChanges applies the payload changes to the source in place
func ApplyGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload) error {
if err := ApplyBaseGitSourceChanges(src, payload); err != nil {
return err
}
if payload.Authentication == nil {
return nil
}
if *payload.Authentication == (GitAuthenticationUpdatePayload{}) {
src.Git.Authentication = nil
return nil
}
src.Git.Authentication = ApplyAuthChanges(src.Git.Authentication, *payload.Authentication)
return nil
}
// ApplyBaseGitSourceChanges applies the non-authentication field changes (name,
// URL, reference, TLS) to the source in place, ensuring src.Git is set
func ApplyBaseGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePayload) error {
if src.Type != portainer.SourceTypeGit {
return ErrNotGitSource
}
@@ -147,41 +119,55 @@ func ApplyBaseGitSourceChanges(src *portainer.Source, payload GitSourceUpdatePay
src.Name = *payload.Name
}
if src.Git == nil {
src.Git = &gittypes.RepoConfig{}
gitConfig := src.Git
if gitConfig == nil {
gitConfig = &gittypes.RepoConfig{}
}
if payload.URL != nil {
src.Git.URL = *payload.URL
gitConfig.URL = *payload.URL
}
if payload.ReferenceName != nil {
src.Git.ReferenceName = *payload.ReferenceName
gitConfig.ReferenceName = *payload.ReferenceName
}
if payload.TLSSkipVerify != nil {
src.Git.TLSSkipVerify = *payload.TLSSkipVerify
gitConfig.TLSSkipVerify = *payload.TLSSkipVerify
}
var auth *gittypes.GitAuthentication
if payload.Authentication == nil {
auth = gitConfig.Authentication
} else if *payload.Authentication != (GitAuthenticationUpdatePayload{}) {
existing := gitConfig.Authentication
if existing != nil {
copied := *existing
auth = &copied
} else {
auth = &gittypes.GitAuthentication{}
}
authPayload := *payload.Authentication
if authPayload.AuthorizationType != nil {
auth.AuthorizationType = *authPayload.AuthorizationType
}
if authPayload.Username != nil {
auth.Username = *authPayload.Username
}
if authPayload.Password != nil {
auth.Password = *authPayload.Password
}
if authPayload.Provider != nil {
auth.Provider = *authPayload.Provider
}
}
gitConfig.Authentication = auth
src.Git = gitConfig
return nil
}
// ApplyAuthChanges returns a copy of the existing authentication (or a fresh
// one) with the basic credential changes applied.
func ApplyAuthChanges(existing *gittypes.GitAuthentication, payload GitAuthenticationUpdatePayload) *gittypes.GitAuthentication {
auth := &gittypes.GitAuthentication{}
if existing != nil {
copied := *existing
auth = &copied
}
if payload.Username != nil {
auth.Username = *payload.Username
}
if payload.Password != nil {
auth.Password = *payload.Password
}
return auth
}

View File

@@ -300,57 +300,6 @@ func TestGitSourceUpdate_MalformedJSON(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
existing := &portainer.Source{
Name: "existing",
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/org/repo.git",
Authentication: &gittypes.GitAuthentication{
Username: "alice",
Password: "secret",
},
},
}
if err := tx.Source().Create(existing); err != nil {
return err
}
other := &portainer.Source{
Name: "other",
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo.git"},
}
if err := tx.Source().Create(other); err != nil {
return err
}
srcID = other.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
alice := "alice"
secret := "secret"
body, err := json.Marshal(GitSourceUpdatePayload{
Authentication: &GitAuthenticationUpdatePayload{
Username: &alice,
Password: &secret,
},
})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body))
require.Equal(t, http.StatusConflict, rr.Code)
}
func TestGitSourceUpdate_NonNumericID(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)

View File

@@ -20,8 +20,12 @@ func (h *Handler) buildSource(ctx context.Context, src *portainer.Source, stats
}
url := ""
var provider gittypes.GitProvider
if src.Git != nil {
url = gittypes.SanitizeURL(src.Git.URL)
if src.Git.Authentication != nil {
provider = src.Git.Authentication.Provider
}
}
return Source{
@@ -31,6 +35,7 @@ func (h *Handler) buildSource(ctx context.Context, src *portainer.Source, stats
URL: url,
Status: status,
Error: sourceErr,
Provider: provider,
UsedBy: stats.WorkflowCount,
Environments: len(stats.EndpointIDs),
LastSync: stats.LastSync,

View File

@@ -49,15 +49,15 @@ func TestRedactWorkflowCredentials(t *testing.T) {
func TestBuildAutoUpdateInfo(t *testing.T) {
t.Parallel()
assert.Nil(t, BuildAutoUpdateInfo(nil))
assert.Nil(t, BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{}))
assert.Nil(t, buildAutoUpdateInfo(nil))
assert.Nil(t, buildAutoUpdateInfo(&portainer.AutoUpdateSettings{}))
got := BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{Interval: "5m"})
got := buildAutoUpdateInfo(&portainer.AutoUpdateSettings{Interval: "5m"})
require.NotNil(t, got)
assert.Equal(t, "Interval", got.Mechanism)
assert.Equal(t, "5m", got.FetchInterval)
got = BuildAutoUpdateInfo(&portainer.AutoUpdateSettings{Webhook: "abc123"})
got = buildAutoUpdateInfo(&portainer.AutoUpdateSettings{Webhook: "abc123"})
require.NotNil(t, got)
assert.Equal(t, "Webhook", got.Mechanism)
assert.Empty(t, got.FetchInterval)

View File

@@ -16,7 +16,6 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/rs/zerolog/log"
"github.com/pkg/errors"
@@ -66,10 +65,6 @@ func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *htt
return httperror.BadRequest("Invalid Helm install payload", err)
}
if err := ssrf.CheckURL(r.Context(), payload.Repo); err != nil {
return httperror.BadRequest("Repository URL blocked by SSRF policy", err)
}
release, err := handler.installChart(r, payload, dryRun)
if err != nil {
return httperror.InternalServerError("Unable to install a chart", err)

View File

@@ -8,7 +8,6 @@ import (
"github.com/portainer/portainer/pkg/libhelm/options"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/rs/zerolog/log"
"github.com/pkg/errors"
@@ -46,10 +45,6 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo)))
}
if err := ssrf.CheckURL(r.Context(), repo); err != nil {
return httperror.BadRequest("Repository URL blocked by SSRF policy", err)
}
searchOpts := options.SearchRepoOptions{
Repo: repo,
Chart: chart,
@@ -58,8 +53,7 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *
result, err := handler.helmPackageManager.SearchRepo(searchOpts)
if err != nil {
log.Warn().Err(err).Str("repo", repo).Msg("helm repo search failed")
return httperror.InternalServerError("Search failed", errors.New("failed to search Helm repository"))
return httperror.InternalServerError("Search failed", err)
}
w.Header().Set("Content-Type", "text/plain")

View File

@@ -8,7 +8,6 @@ import (
"github.com/portainer/portainer/pkg/libhelm/options"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
@@ -42,10 +41,6 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo)))
}
if err := ssrf.CheckURL(r.Context(), repo); err != nil {
return httperror.BadRequest("Repository URL blocked by SSRF policy", err)
}
chart := r.URL.Query().Get("chart")
if chart == "" {
return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter"))
@@ -70,8 +65,7 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
}
result, err := handler.helmPackageManager.Show(showOptions)
if err != nil {
log.Warn().Err(err).Str("repo", repo).Str("chart", chart).Msg("helm show failed")
return httperror.InternalServerError("Unable to show chart", errors.New("failed to retrieve Helm chart information"))
return httperror.InternalServerError("Unable to show chart", err)
}
w.Header().Set("Content-Type", "text/plain")

View File

@@ -1,124 +0,0 @@
package kubernetes
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
kubeclient "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// denyingBouncer satisfies security.BouncerService but rejects AuthorizedEndpointOperation.
type denyingBouncer struct{}
func (denyingBouncer) PublicAccess(h http.Handler) http.Handler { return h }
func (denyingBouncer) AdminAccess(h http.Handler) http.Handler { return h }
func (denyingBouncer) RestrictedAccess(h http.Handler) http.Handler { return h }
func (denyingBouncer) TeamLeaderAccess(h http.Handler) http.Handler { return h }
func (denyingBouncer) AuthenticatedAccess(h http.Handler) http.Handler { return h }
func (denyingBouncer) EdgeComputeOperation(h http.Handler) http.Handler { return h }
func (denyingBouncer) AuthorizedEndpointOperation(_ *http.Request, _ *portainer.Endpoint) error {
return security.ErrAuthorizationRequired
}
func (denyingBouncer) AuthorizedEdgeEndpointOperation(_ *http.Request, _ *portainer.Endpoint) error {
return nil
}
func (denyingBouncer) CookieAuthLookup(*http.Request) (*portainer.TokenData, error) { return nil, nil }
func (denyingBouncer) JWTAuthLookup(*http.Request) (*portainer.TokenData, error) { return nil, nil }
func (denyingBouncer) TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error {
return nil
}
func (denyingBouncer) RevokeJWT(string) {}
func (denyingBouncer) DisableCSP() {}
func newEndpointAuthTestHandler(t *testing.T, bouncer security.BouncerService) (*Handler, *portainer.User, string) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
_, store := datastore.MustNewTestStore(t, true, true)
err := store.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Type: portainer.KubernetesLocalEnvironment,
})
require.NoError(t, err)
u := &portainer.User{Username: "user", Role: portainer.StandardUserRole}
err = store.User().Create(u)
require.NoError(t, err)
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err)
tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
require.NoError(t, err)
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
srvURL, err := url.Parse(srv.URL)
require.NoError(t, err)
cli := testhelpers.NewKubernetesClient()
factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "")
require.NoError(t, err)
authorizationService := authorization.NewService(store)
handler := NewHandler(bouncer, authorizationService, store, jwtService, kubeClusterAccessService, factory, cli)
return handler, u, tk
}
func newEndpointAuthRequest(t *testing.T, method, path string, u *portainer.User, tk string) *http.Request {
t.Helper()
req := httptest.NewRequest(method, path, nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
req = req.WithContext(ctx)
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: false, UserID: u.ID})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, tk)
return req
}
func TestEndpointAuthorization_DeniedUser_Returns403(t *testing.T) {
t.Parallel()
routes := []struct {
method string
path string
}{
{http.MethodGet, "/kubernetes/1/namespaces"},
{http.MethodGet, "/kubernetes/1/configmaps"},
{http.MethodGet, "/kubernetes/1/services"},
{http.MethodGet, "/kubernetes/1/secrets"},
{http.MethodGet, "/kubernetes/1/ingresses"},
}
handler, u, tk := newEndpointAuthTestHandler(t, denyingBouncer{})
for _, route := range routes {
t.Run(route.method+" "+route.path, func(t *testing.T) {
req := newEndpointAuthRequest(t, route.method, route.path, u, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
})
}
}

View File

@@ -50,7 +50,6 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// endpoints
endpointRouter := kubeRouter.PathPrefix("/{id}").Subrouter()
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
endpointRouter.Use(middlewares.CheckEndpointAuthorization(bouncer))
endpointRouter.Use(h.kubeClientMiddleware)
endpointRouter.Handle("/applications", httperror.LoggerHandler(h.GetAllKubernetesApplications)).Methods(http.MethodGet)

View File

@@ -14,7 +14,6 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/portainer/portainer/pkg/validate"
"github.com/pkg/errors"
@@ -75,12 +74,6 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format")
}
if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" {
if err := ssrf.CheckURL(r.Context(), *payload.HelmRepositoryURL); err != nil {
return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format")
}
}
if payload.UserSessionTimeout != nil {
if _, err := time.ParseDuration(*payload.UserSessionTimeout); err != nil {
return errors.New("Invalid user session timeout")

View File

@@ -8,7 +8,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -172,18 +171,15 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
type composeStackFromGitRepositoryPayload struct {
// Name of the stack
Name string `example:"myStack" validate:"required"`
// SourceID references an existing Source for git credentials/URL.
// When set, the inline URL and authentication fields are ignored.
SourceID portainer.SourceID `example:"1"`
// Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
RepositoryURL string `example:"https://github.com/openfaas/faas"`
// URL of a Git repository hosting the Stack file
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
// Reference name of a Git repository hosting the Stack file
RepositoryReferenceName string `example:"refs/heads/master"`
// Deprecated: use SourceID instead. Use basic authentication to clone the Git repository.
// Use basic authentication to clone the Git repository
RepositoryAuthentication bool `example:"true"`
// Deprecated: use SourceID instead. Username used in basic authentication.
// Username used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryUsername string `example:"myGitUsername"`
// Deprecated: use SourceID instead. Password used in basic authentication.
// Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string `example:"myGitPassword"`
// Path to the Stack file inside the Git repository
ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"`
@@ -195,15 +191,14 @@ type composeStackFromGitRepositoryPayload struct {
Env []portainer.Pair
// Whether the stack is from a app template
FromAppTemplate bool `example:"false"`
// Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
}
func createStackPayloadFromComposeGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload {
func createStackPayloadFromComposeGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool) stackbuilders.StackPayload {
return stackbuilders.StackPayload{
Name: name,
RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{
SourceID: sourceID,
URL: repoUrl,
ReferenceName: repoReference,
Authentication: repoAuthentication,
@@ -223,14 +218,11 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
if len(payload.Name) == 0 {
return errors.New("Invalid stack name")
}
if payload.SourceID == 0 {
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
return update.ValidateAutoUpdateSettings(payload.AutoUpdate)
@@ -279,12 +271,6 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
}
}
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
return httpErr
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
@@ -302,7 +288,6 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
payload.Env,
payload.FromAppTemplate,
payload.TLSSkipVerify,
payload.SourceID,
)
composeStackBuilder := stackbuilders.CreateComposeStackGitBuilder(securityContext,

View File

@@ -1,31 +0,0 @@
package stacks
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func TestComposeGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) {
t.Parallel()
payload := &composeStackFromGitRepositoryPayload{
Name: "mystack",
SourceID: portainer.SourceID(1),
// RepositoryURL intentionally omitted
}
err := payload.Validate(nil)
assert.NoError(t, err)
}
func TestComposeGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) {
t.Parallel()
payload := &composeStackFromGitRepositoryPayload{
Name: "mystack",
// SourceID and RepositoryURL both omitted
}
err := payload.Validate(nil)
assert.Error(t, err)
}

View File

@@ -6,7 +6,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -14,7 +13,6 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/portainer/portainer/pkg/validate"
"github.com/pkg/errors"
@@ -39,34 +37,25 @@ func createStackPayloadFromK8sFileContentPayload(name, namespace, fileContent st
}
type kubernetesGitDeploymentPayload struct {
StackName string
ComposeFormat bool
Namespace string
// SourceID references an existing Source for git credentials/URL.
// When set, the inline URL and authentication fields are ignored.
SourceID portainer.SourceID `example:"1"`
// Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
RepositoryURL string
// Deprecated: use SourceID instead. Reference name of a Git repository hosting the Stack file.
RepositoryReferenceName string
// Deprecated: use SourceID instead. Use basic authentication to clone the Git repository.
StackName string
ComposeFormat bool
Namespace string
RepositoryURL string
RepositoryReferenceName string
RepositoryAuthentication bool
// Deprecated: use SourceID instead. Username used in basic authentication.
RepositoryUsername string
// Deprecated: use SourceID instead. Password used in basic authentication.
RepositoryPassword string
ManifestFile string
AdditionalFiles []string
AutoUpdate *portainer.AutoUpdateSettings
// Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
RepositoryUsername string
RepositoryPassword string
ManifestFile string
AdditionalFiles []string
AutoUpdate *portainer.AutoUpdateSettings
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
}
func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload {
func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, repoSkipSSLVerify bool) stackbuilders.StackPayload {
return stackbuilders.StackPayload{
StackName: name,
RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{
SourceID: sourceID,
URL: repoUrl,
ReferenceName: repoReference,
Authentication: repoAuthentication,
@@ -105,13 +94,12 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro
}
func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
if payload.SourceID == 0 {
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
if len(payload.ManifestFile) == 0 {
@@ -126,10 +114,6 @@ func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request)
return errors.New("Invalid manifest URL")
}
if err := ssrf.CheckURL(r.Context(), payload.ManifestURL); err != nil {
return err
}
return nil
}
@@ -234,12 +218,6 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
}
}
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
return httpErr
}
}
stackPayload := createStackPayloadFromK8sGitPayload(payload.StackName,
payload.RepositoryURL,
payload.RepositoryReferenceName,
@@ -252,7 +230,6 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
payload.AdditionalFiles,
payload.AutoUpdate,
payload.TLSSkipVerify,
payload.SourceID,
)
k8sStackBuilder := stackbuilders.CreateKubernetesStackGitBuilder(handler.DataStore,

View File

@@ -1,67 +0,0 @@
package stacks
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require"
)
func TestKubernetesGitDeploymentPayloadValidate_WithSourceID_URLNotRequired(t *testing.T) {
t.Parallel()
p := kubernetesGitDeploymentPayload{
SourceID: portainer.SourceID(1),
ManifestFile: "manifest.yaml",
}
err := p.Validate(nil)
require.NoError(t, err)
}
func TestKubernetesGitDeploymentPayloadValidate_WithSourceID_AuthNotRequired(t *testing.T) {
t.Parallel()
p := kubernetesGitDeploymentPayload{
SourceID: portainer.SourceID(1),
RepositoryAuthentication: true,
// Password intentionally omitted — should not fail when SourceID is set
ManifestFile: "manifest.yaml",
}
err := p.Validate(nil)
require.NoError(t, err)
}
func TestKubernetesGitDeploymentPayloadValidate_WithoutSourceID_URLRequired(t *testing.T) {
t.Parallel()
p := kubernetesGitDeploymentPayload{
ManifestFile: "manifest.yaml",
// SourceID and RepositoryURL both omitted
}
err := p.Validate(nil)
require.Error(t, err)
}
func TestCreateStackPayloadFromK8sGitPayload_WithSourceID(t *testing.T) {
t.Parallel()
p := createStackPayloadFromK8sGitPayload(
"k8s-stack",
"",
"",
"",
"",
false,
false,
"default",
"manifest.yaml",
nil,
nil,
false,
portainer.SourceID(7),
)
require.Equal(t, portainer.SourceID(7), p.SourceID)
require.Equal(t, "manifest.yaml", p.ManifestFile)
}

View File

@@ -6,7 +6,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackbuilders"
"github.com/portainer/portainer/api/stacks/stackutils"
@@ -113,18 +112,15 @@ type swarmStackFromGitRepositoryPayload struct {
// A list of environment variables used during stack deployment
Env []portainer.Pair
// SourceID references an existing Source for git credentials/URL.
// When set, the inline URL and authentication fields are ignored.
SourceID portainer.SourceID `example:"1"`
// Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
RepositoryURL string `example:"https://github.com/openfaas/faas"`
// URL of a Git repository hosting the Stack file
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
// Reference name of a Git repository hosting the Stack file
RepositoryReferenceName string `example:"refs/heads/master"`
// Deprecated: use SourceID instead. Use basic authentication to clone the Git repository.
// Use basic authentication to clone the Git repository
RepositoryAuthentication bool `example:"true"`
// Deprecated: use SourceID instead. Username used in basic authentication.
// Username used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryUsername string `example:"myGitUsername"`
// Deprecated: use SourceID instead. Password used in basic authentication.
// Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string `example:"myGitPassword"`
// Whether the stack is from a app template
FromAppTemplate bool `example:"false"`
@@ -134,7 +130,7 @@ type swarmStackFromGitRepositoryPayload struct {
AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"`
// Optional GitOps update configuration
AutoUpdate *portainer.AutoUpdateSettings
// Deprecated: use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
// TLSSkipVerify skips SSL verification when cloning the Git repository
TLSSkipVerify bool `example:"false"`
}
@@ -145,25 +141,21 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
if len(payload.SwarmID) == 0 {
return errors.New("Invalid Swarm ID")
}
if payload.SourceID == 0 {
if len(payload.RepositoryURL) == 0 || !valid.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
if len(payload.RepositoryURL) == 0 || !valid.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
return update.ValidateAutoUpdateSettings(payload.AutoUpdate)
}
func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool, sourceID portainer.SourceID) stackbuilders.StackPayload {
func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.AutoUpdateSettings, env []portainer.Pair, fromAppTemplate bool, repoSkipSSLVerify bool) stackbuilders.StackPayload {
return stackbuilders.StackPayload{
Name: name,
SwarmID: swarmID,
RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{
SourceID: sourceID,
URL: repoUrl,
ReferenceName: repoReference,
Authentication: repoAuthentication,
@@ -218,12 +210,6 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
}
}
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
return httpErr
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
@@ -242,7 +228,6 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
payload.Env,
payload.FromAppTemplate,
payload.TLSSkipVerify,
payload.SourceID,
)
swarmStackBuilder := stackbuilders.CreateSwarmStackGitBuilder(securityContext,

View File

@@ -1,31 +0,0 @@
package stacks
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func TestSwarmGitPayload_ValidateWithSourceID_URLNotRequired(t *testing.T) {
t.Parallel()
payload := &swarmStackFromGitRepositoryPayload{
Name: "myswarm",
SwarmID: "swarm-abc",
SourceID: portainer.SourceID(1),
}
err := payload.Validate(nil)
assert.NoError(t, err)
}
func TestSwarmGitPayload_ValidateWithoutSourceID_URLRequired(t *testing.T) {
t.Parallel()
payload := &swarmStackFromGitRepositoryPayload{
Name: "myswarm",
SwarmID: "swarm-abc",
}
err := payload.Validate(nil)
assert.Error(t, err)
}

View File

@@ -7,12 +7,6 @@ import (
"github.com/portainer/portainer/api/gitops/workflows"
)
// stackResponse extends a Stack response with the git source identifier.
type stackResponse struct {
portainer.Stack
GitSourceId portainer.SourceID `json:"GitSourceId,omitempty"`
}
// loadGitConfigForStack reads the merged GitConfig (Source URL/auth/TLS + Artifact ref/path/hash)
// and the SourceID for the given stack.
func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) {
@@ -24,40 +18,10 @@ func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.Wor
return workflows.MergeSourceAndFile(src, file), src.ID, nil
}
// saveStackGitConfig persists the stack's git settings. When newSourceID is non-zero the stack's
// artifact is repointed to that existing Source (selected by the caller) without modifying any
// Source's git config; otherwise the target Source is derived from cfg.URL.
func saveStackGitConfig(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
matchArtifact := func(a portainer.Artifact) bool {
func saveStackGitConfig(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
return workflows.SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool {
return a.StackID == stackID
}
if newSourceID != 0 {
return workflows.SaveWorkflowArtifact(tx, workflowID, matchArtifact, oldSourceID, portainer.ArtifactFile{
SourceID: newSourceID,
Ref: cfg.ReferenceName,
Path: cfg.ConfigFilePath,
Hash: cfg.ConfigHash,
})
}
return workflows.SaveWorkflowGitConfig(tx, workflowID, matchArtifact, oldSourceID, cfg)
}
// newStackResponse fills stack.GitConfig and returns a response that also includes GitSourceId.
func newStackResponse(tx dataservices.DataStoreTx, stack *portainer.Stack) (*stackResponse, error) {
if stack.WorkflowID == 0 {
return &stackResponse{Stack: *stack}, nil
}
gitConfig, gitSourceID, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID)
if err != nil {
return nil, err
}
stack.GitConfig = gittypes.SanitizeRepoConfig(gitConfig)
return &stackResponse{Stack: *stack, GitSourceId: gitSourceID}, nil
}, oldSourceID, cfg)
}
// fillStackGitConfig populates stack.GitConfig from the merged Source+Artifact for backwards-compatible responses.

View File

@@ -23,7 +23,7 @@ import (
// @security jwt
// @produce json
// @param id path int true "Stack identifier"
// @success 200 {object} stackResponse "Success"
// @success 200 {object} portainer.Stack "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Stack not found"
@@ -91,10 +91,9 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
}
}
resp, err := newStackResponse(handler.DataStore, stack)
if err != nil {
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return response.JSON(w, resp)
return response.JSON(w, stack)
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments"
@@ -23,25 +22,17 @@ import (
)
type stackGitUpdatePayload struct {
AutoUpdate *portainer.AutoUpdateSettings
Env []portainer.Pair
Prune bool
ConfigFilePath string
AdditionalFiles []string
RepositoryReferenceName string
// SourceID references an existing Source for git credentials/URL.
// When set, the inline URL and authentication fields are ignored.
SourceID portainer.SourceID
// Deprecated: use SourceID instead. URL of a Git repository hosting the Stack file.
RepositoryURL string
// Deprecated: use SourceID instead. Use basic authentication to clone the Git repository.
AutoUpdate *portainer.AutoUpdateSettings
Env []portainer.Pair
Prune bool
RepositoryURL string
ConfigFilePath string
AdditionalFiles []string
RepositoryReferenceName string
RepositoryAuthentication bool
// Deprecated: use SourceID instead. Username used in basic authentication.
RepositoryUsername string
// Deprecated: use SourceID instead. Password used in basic authentication.
RepositoryPassword string
// Deprecated: use SourceID instead. Skip TLS verification when cloning the Git repository.
TLSSkipVerify bool
RepositoryUsername string
RepositoryPassword string
TLSSkipVerify bool
}
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
@@ -50,7 +41,7 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
// @id StackUpdateGit
// @summary Update a stack's Git configs
// @description Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate. When SourceID is set, URL/auth/TLS are taken from the referenced Source.
// @description Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate
// @description **Access policy**: authenticated
// @tags stacks
// @security ApiKeyAuth
@@ -60,7 +51,7 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
// @param id path int true "Stack identifier"
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack."
// @param body body stackGitUpdatePayload true "Git configs for pull and redeploy a stack"
// @success 200 {object} stackResponse "Success"
// @success 200 {object} portainer.Stack "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Not found"
@@ -161,7 +152,6 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
}
// Record the current git config as the deployment baseline if it was never set (legacy stacks).
if stack.CurrentDeploymentInfo == nil {
stack.CurrentDeploymentInfo = &portainer.StackDeploymentInfo{
RepositoryURL: gitConfig.URL,
@@ -169,12 +159,15 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
ConfigFilePath: gitConfig.ConfigFilePath,
AdditionalFiles: stack.AdditionalFiles,
ConfigHash: gitConfig.ConfigHash,
SourceID: sourceID,
}
}
// Update gitConfig based on payload; the updated config is saved to Source (not stack.GitConfig).
gitConfig.ReferenceName = payload.RepositoryReferenceName
gitConfig.TLSSkipVerify = payload.TLSSkipVerify
if payload.RepositoryURL != "" {
gitConfig.URL = payload.RepositoryURL
}
if payload.ConfigFilePath != "" {
gitConfig.ConfigFilePath = payload.ConfigFilePath
}
@@ -193,48 +186,32 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
stack.Option = &portainer.StackOption{Prune: payload.Prune}
}
if payload.SourceID != 0 {
src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID)
if httpErr != nil {
return httpErr
if payload.RepositoryAuthentication {
password := payload.RepositoryPassword
// When the existing stack is using the custom username/password and the password is not updated,
// the stack should keep using the saved username/password
if password == "" && gitConfig.Authentication != nil {
password = gitConfig.Authentication.Password
}
if src.Git == nil {
return httperror.BadRequest("Source has no git configuration", errors.New("source has no git config"))
gitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: password,
}
if _, err := handler.GitService.LatestCommitID(
context.TODO(),
gitConfig.URL,
gitConfig.ReferenceName,
gitConfig.Authentication.Username,
gitConfig.Authentication.Password,
gitConfig.TLSSkipVerify,
); err != nil {
return httperror.InternalServerError("Unable to fetch git repository", err)
}
} else {
gitConfig.TLSSkipVerify = payload.TLSSkipVerify
if payload.RepositoryURL != "" {
gitConfig.URL = payload.RepositoryURL
}
if payload.RepositoryAuthentication {
password := payload.RepositoryPassword
// When the existing stack is using the custom username/password and the password is not updated,
// the stack should keep using the saved username/password
if password == "" && gitConfig.Authentication != nil {
password = gitConfig.Authentication.Password
}
gitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: password,
}
if _, err := handler.GitService.LatestCommitID(
context.TODO(),
gitConfig.URL,
gitConfig.ReferenceName,
gitConfig.Authentication.Username,
gitConfig.Authentication.Password,
gitConfig.TLSSkipVerify,
); err != nil {
return httperror.InternalServerError("Unable to fetch git repository", err)
}
} else {
gitConfig.Authentication = nil
}
gitConfig.Authentication = nil
}
if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" {
@@ -245,20 +222,18 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
}
}
var resp *stackResponse
// Save the updated stack and git config to DB.
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return err
}
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil {
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, gitConfig); err != nil {
return err
}
var err error
resp, err = newStackResponse(tx, stack)
return err
return fillStackGitConfig(tx, stack)
}); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
return response.JSON(w, resp)
return response.JSON(w, stack)
}

View File

@@ -211,7 +211,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
ConfigFilePath: gitConfig.ConfigFilePath,
AdditionalFiles: stack.AdditionalFiles,
ConfigHash: newHash,
SourceID: sourceID,
}
stack.UpdatedBy = user.Username
@@ -254,7 +253,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return err
}
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, gitConfig); err != nil {
return err
}

View File

@@ -111,7 +111,7 @@ func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *ht
stack.AutoUpdate.JobID = jobID
}
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, gitConfig); err != nil {
return httperror.InternalServerError("Unable to update source git config", err)
}

View File

@@ -11,7 +11,6 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/pkg/errors"
)
@@ -25,11 +24,7 @@ type addHelmRepoUrlPayload struct {
URL string `json:"url"`
}
func (p *addHelmRepoUrlPayload) Validate(r *http.Request) error {
if err := ssrf.CheckURL(r.Context(), p.URL); err != nil {
return err
}
func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
}

View File

@@ -1,34 +0,0 @@
package websocket
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestWebsocketAttach_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on
// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027).
func TestWebsocketAttach_deniesUnauthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(user)
})
require.NoError(t, err)
// attach requires a hexadecimal `id` query parameter to reach the authorization check.
req := httptest.NewRequest(http.MethodGet, "/websocket/attach?id=abcdef&endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
handlerErr := handler.websocketAttach(httptest.NewRecorder(), req)
require.NotNil(t, handlerErr, "expected an authorization error for a denied environment")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode)
}

View File

@@ -1,34 +0,0 @@
package websocket
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestWebsocketExec_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on
// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027).
func TestWebsocketExec_deniesUnauthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(user)
})
require.NoError(t, err)
// exec requires a hexadecimal `id` query parameter to reach the authorization check.
req := httptest.NewRequest(http.MethodGet, "/websocket/exec?id=abcdef&endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
handlerErr := handler.websocketExec(httptest.NewRecorder(), req)
require.NotNil(t, handlerErr, "expected an authorization error for a denied environment")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode)
}

View File

@@ -1,62 +0,0 @@
package websocket
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// podExecQuery is the minimal set of query parameters required to reach the authorization
// check in websocketPodExec.
const podExecQuery = "/websocket/pod?endpointId=2&namespace=default&podName=p&containerName=c&command=sh"
// TestWebsocketPodExec_deniesUnauthorizedEndpoint asserts a non-admin with no access policy on
// the environment is rejected with 403 — the environment-access (L2) gate (BE-13027).
func TestWebsocketPodExec_deniesUnauthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(user)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, podExecQuery, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
handlerErr := handler.websocketPodExec(httptest.NewRecorder(), req)
require.NotNil(t, handlerErr, "expected an authorization error for a denied environment")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode)
}
// TestWebsocketPodExec_allowsAuthorizedNonAdmin asserts a non-admin granted environment access
// passes authorization (reaching the nil client via getToken and panicking). CE has no
// operation-level (L3) layer, so environment access is the only gate (BE-13027).
func TestWebsocketPodExec_allowsAuthorizedNonAdmin(t *testing.T) {
handler, endpoint := newWebsocketTestHandler(t)
user := &portainer.User{Username: "standard", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := tx.User().Create(user); err != nil {
return err
}
// Access is by membership; the access policy's role is irrelevant to the CE access decision.
endpoint.UserAccessPolicies = portainer.UserAccessPolicies{user.ID: {}}
return tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, podExecQuery, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
assert.Panics(t, func() {
_ = handler.websocketPodExec(httptest.NewRecorder(), req)
})
}

View File

@@ -21,7 +21,6 @@ import (
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Environment not found"
// @failure 500 "Server error"
// @router /websocket/kubernetes-shell [get]
func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -37,13 +36,9 @@ func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
}
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
return httperror.Forbidden("Permission denied to access environment", err)
}
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)

View File

@@ -1,101 +0,0 @@
package websocket
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newWebsocketTestHandler builds a websocket Handler backed by a test store with a single
// Kubernetes environment (ID 2) and a real bouncer, returning both the handler and that
// endpoint so callers can grant access via its UserAccessPolicies. KubernetesClientFactory is
// left nil so any handler that proceeds past authorization trips a clear panic. Shared by the
// exec/attach/pod/kubernetes-shell L2 tests (BE-13027).
func newWebsocketTestHandler(t *testing.T) (*Handler, *portainer.Endpoint) {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, false)
endpoint := &portainer.Endpoint{
ID: 2,
Name: "target-env",
Type: portainer.AgentOnKubernetesEnvironment,
GroupID: 1,
}
require.NoError(t, store.Endpoint().Create(endpoint))
bouncer := security.NewRequestBouncer(t.Context(), store, nil, nil)
handler := &Handler{
DataStore: store,
requestBouncer: bouncer,
// KubernetesClientFactory intentionally left nil.
}
return handler, endpoint
}
// TestWebsocketShellPodExec_deniesUnauthorizedEndpoint asserts a non-admin with no access
// policy on the environment is rejected with 403 — the environment-access (L2) gate (BE-13027).
func TestWebsocketShellPodExec_deniesUnauthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
user := &portainer.User{Username: "restricted", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.User().Create(user)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
handlerErr := handler.websocketShellPodExec(httptest.NewRecorder(), req)
require.NotNil(t, handlerErr, "expected an authorization error for a denied environment")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode)
}
// TestWebsocketShellPodExec_allowsAuthorizedEndpoint asserts an admin passes authorization and
// reaches the nil KubernetesClientFactory (panic proves auth did not block the request) (BE-13027).
func TestWebsocketShellPodExec_allowsAuthorizedEndpoint(t *testing.T) {
handler, _ := newWebsocketTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
assert.Panics(t, func() {
_ = handler.websocketShellPodExec(httptest.NewRecorder(), req)
})
}
// TestWebsocketShellPodExec_allowsAuthorizedNonAdmin asserts a non-admin granted environment
// access passes authorization (reaching the nil client and panicking). CE has no operation-level
// (L3) layer, so environment access is the only gate (BE-13027).
func TestWebsocketShellPodExec_allowsAuthorizedNonAdmin(t *testing.T) {
handler, endpoint := newWebsocketTestHandler(t)
user := &portainer.User{Username: "standard", Role: portainer.StandardUserRole}
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := tx.User().Create(user); err != nil {
return err
}
// Access is by membership; the access policy's role is irrelevant to the CE access decision.
endpoint.UserAccessPolicies = portainer.UserAccessPolicies{user.ID: {}}
return tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/websocket/kubernetes-shell?endpointId=2", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: user.ID, Role: portainer.StandardUserRole}))
assert.Panics(t, func() {
_ = handler.websocketShellPodExec(httptest.NewRecorder(), req)
})
}

View File

@@ -52,15 +52,14 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
endpointURL.Scheme = "https"
if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(tlsConfig)
innerTransport = ssrf.WrapTransportInternal(&http.Transport{TLSClientConfig: tlsConfig})
} else {
innerTransport = ssrf.NewTransport(tlsConfig)
innerTransport.Protocols = ssrf.HTTP1Only()
innerTransport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
}
} else if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(nil)
innerTransport = ssrf.WrapTransportInternal(&http.Transport{})
} else {
innerTransport = ssrf.NewTransport(nil)
innerTransport = ssrf.WrapTransport(&http.Transport{})
}
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)

View File

@@ -68,14 +68,14 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
endpointURL.Scheme = "https"
if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(tlsConfig)
innerTransport = ssrf.WrapTransportInternal(&http.Transport{TLSClientConfig: tlsConfig})
} else {
innerTransport = ssrf.NewTransport(tlsConfig)
innerTransport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
}
} else if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(nil)
innerTransport = ssrf.WrapTransportInternal(&http.Transport{})
} else {
innerTransport = ssrf.NewTransport(nil)
innerTransport = ssrf.WrapTransport(&http.Transport{})
}
dockerTransport, err := docker.NewTransport(transportParameters, innerTransport, factory.gitService, factory.snapshotService)

View File

@@ -22,7 +22,6 @@ import (
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/slicesx"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
@@ -507,11 +506,6 @@ func (transport *Transport) updateDefaultGitBranch(request *http.Request) error
}
repositoryURL := remote[:len(remote)-4]
if err := ssrf.CheckURL(request.Context(), repositoryURL); err != nil {
return err
}
latestCommitID, err := transport.gitService.LatestCommitID(
request.Context(),
repositoryURL,

View File

@@ -3,13 +3,11 @@
package factory
import (
"context"
"net"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) {
@@ -33,11 +31,9 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
}
func newSocketTransport(socketPath string) *http.Transport {
d := &net.Dialer{}
t := ssrf.NewInternalTransport(nil)
t.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
return d.DialContext(ctx, "unix", socketPath)
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return net.Dial("unix", socketPath)
},
}
return t
}

View File

@@ -3,14 +3,12 @@
package factory
import (
"context"
"net"
"net/http"
"github.com/Microsoft/go-winio"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) {
@@ -34,10 +32,9 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
}
func newNamedPipeTransport(namedPipePath string) *http.Transport {
t := ssrf.NewInternalTransport(nil)
t.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
return winio.DialPipe(namedPipePath, nil)
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return winio.DialPipe(namedPipePath, nil)
},
}
return t
}

View File

@@ -94,7 +94,7 @@ func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(ssrf.NewTransport(nil)), // Use ORAS retry transport for consistent rate limiting and error handling
transport: retry.NewTransport(ssrf.WrapTransport(&http.Transport{})), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}

View File

@@ -94,7 +94,7 @@ type Transport struct {
// interface for proxying requests to the Gitlab API.
func NewTransport() *Transport {
return &Transport{
httpTransport: ssrf.NewTransport(nil),
httpTransport: ssrf.WrapTransport(&http.Transport{}),
}
}
@@ -119,7 +119,7 @@ func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(ssrf.NewTransport(nil)), // Use ORAS retry transport for consistent rate limiting and error handling
transport: retry.NewTransport(ssrf.WrapTransport(&http.Transport{})), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}

View File

@@ -23,12 +23,11 @@ func NewAgentTransport(signatureService portainer.DigitalSignatureService, token
return nil, err
}
httpTransport := ssrf.NewTransport(tlsConfig)
httpTransport.Protocols = ssrf.HTTP1Only()
transport := &agentTransport{
baseTransport: newBaseTransport(
httpTransport,
ssrf.WrapTransport(&http.Transport{
TLSClientConfig: tlsConfig,
}),
tokenManager,
endpoint,
k8sClientFactory,

View File

@@ -22,7 +22,7 @@ func NewEdgeTransport(dataStore dataservices.DataStore, signatureService portain
reverseTunnelService: reverseTunnelService,
signatureService: signatureService,
baseTransport: newBaseTransport(
ssrf.NewInternalTransport(nil),
ssrf.WrapTransportInternal(&http.Transport{}),
tokenManager,
endpoint,
k8sClientFactory,

View File

@@ -23,7 +23,9 @@ func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint,
transport := &localTransport{
baseTransport: newBaseTransport(
ssrf.NewInternalTransport(config),
ssrf.WrapTransportInternal(&http.Transport{
TLSClientConfig: config,
}),
tokenManager,
endpoint,
k8sClientFactory,

Some files were not shown because too many files have changed in this diff Show More