VYPR
High severity7.1GHSA Advisory· Published May 7, 2026· Updated May 11, 2026

CVE-2026-41644

CVE-2026-41644

Description

monetr is a budgeting application for recurring expenses. Prior to version 1.12.5, a server-side request forgery (SSRF) vulnerability in monetr's Lunch Flow integration allowed any authenticated user on a self-hosted instance to cause the monetr server to issue HTTP GET requests to arbitrary URLs supplied by the caller, with the response body from non-200 upstream responses reflected back in the API error message. This issue has been patched in version 1.12.5.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/monetr/monetrGo
< 1.12.51.12.5

Affected products

2
  • Monetr/MonetrGHSA2 versions
    < 1.12.5+ 1 more
    • (no CPE)range: < 1.12.5
    • cpe:2.3:a:monetr:monetr:*:*:*:*:*:*:*:*range: <1.12.5

Patches

1
c260caa3c573

fix(api): Fixed SSRF via Lunch Flow onboarding (#3122)

https://github.com/monetr/monetrElliot CourantApr 18, 2026via ghsa
20 files changed · +445 69
  • compose/monetr.yaml+5 0 modified
    @@ -34,6 +34,11 @@ email:
     logging:
       format: text
       level: trace
    +lunchFlow:
    +  enabled: true
    +  allowedApiUrls:
    +    - https://lunchflow.app/api/v1
    +    - https://bogus.example.com/api/v1
     postgresql:
       address: postgres
       database: postgres
    
  • docs/src/v1/en/documentation/configure/lunch_flow.mdx+6 3 modified
    @@ -12,8 +12,11 @@ setting up Lunch Flow you can follow the guide here: [Integrate with Lunch Flow]
     ```yaml title="config.yaml"
     lunchflow:
       enabled: true
    +  allowedApiUrls:
    +    - https://lunchflow.app/api/v1
     ```
     
    -| **Name**  | **Type** | **Default** | **Description**                                          |
    -| ---       | ---      | ---         | ---                                                      |
    -| `enabled` | Boolean  | `true`      | Allow Lunch Flow connections to be configured in monetr. |
    +| **Name**         | **Type** | **Default**                        | **Description**                                                                                                                                                                                                                                  |
    +| ---              | ---      | ---                                | ---                                                                                                                                                                                                                                              |
    +| `enabled`        | Boolean  | `true`                             | Allow Lunch Flow connections to be configured in monetr.                                                                                                                                                                                         |
    +| `allowedApiUrls` | Array    | `["https://lunchflow.app/api/v1"]` | Allowlist of URLs that are considered valid base API URLs for new Lunch Flow links. This defaults to the production Lunch Flow API URL, but if you want to use another Lunch Flow compatible API then you need to add it manually to the config. |
    
  • .github/dictionary.txt+1 0 modified
    @@ -4,6 +4,7 @@ API.*
     AVX
     AVX512
     AboutFeatures
    +Allowlist
     BackgroundGradientAnimation
     Biometric
     Bitnami
    
  • interface/src/components/setup/lunchflow/LunchFlowSetupIntro.tsx+46 12 modified
    @@ -1,5 +1,6 @@
     import { useCallback, useMemo } from 'react';
     import type { FormikHelpers } from 'formik';
    +import { useFormikContext } from 'formik';
     import { useSnackbar } from 'notistack';
     import { useNavigate } from 'react-router-dom';
     
    @@ -9,6 +10,7 @@ import FormButton from '@monetr/interface/components/FormButton';
     import FormTextField from '@monetr/interface/components/FormTextField';
     import { layoutVariants } from '@monetr/interface/components/Layout';
     import MForm from '@monetr/interface/components/MForm';
    +import Select, { type SelectOption } from '@monetr/interface/components/Select';
     import LunchFlowSetupLayout from '@monetr/interface/components/setup/lunchflow/LunchFlowSetupLayout';
     import { LunchFlowSetupSteps } from '@monetr/interface/components/setup/lunchflow/LunchFlowSetupSteps';
     import Typography from '@monetr/interface/components/Typography';
    @@ -27,13 +29,16 @@ export default function LunchFlowSetupIntro(): React.JSX.Element {
       const { enqueueSnackbar } = useSnackbar();
       const navigate = useNavigate();
     
    +  const allowedAPIURLs = config.lunchFlowAllowedAPIURLs ?? [];
    +  const initialApiURL = allowedAPIURLs.length > 0 ? allowedAPIURLs[0] : '';
    +
       const initialValues: LunchFlowSetupIntroValues = useMemo(
         () => ({
           name: '',
           apiKey: '',
    -      apiURL: config.lunchFlowDefaultAPIURL,
    +      apiURL: initialApiURL,
         }),
    -    [config],
    +    [initialApiURL],
       );
     
       const submit = useCallback(
    @@ -112,20 +117,49 @@ export default function LunchFlowSetupIntro(): React.JSX.Element {
               required
               type='password'
             />
    -        <FormTextField
    -          className={layoutVariants({ width: 'full' })}
    -          data-1p-ignore
    -          label='API URL'
    -          name='apiURL'
    -          placeholder='https://.../api/v1'
    -          required
    -          spellCheck='false'
    -          type='url'
    -        />
    +        <LunchFlowURLField allowedAPIURLs={allowedAPIURLs} />
             <FormButton type='submit' variant='primary'>
               Next
             </FormButton>
           </MForm>
         </LunchFlowSetupLayout>
       );
     }
    +
    +interface LunchFlowURLFieldProps {
    +  allowedAPIURLs: string[];
    +}
    +
    +function LunchFlowURLField({ allowedAPIURLs }: LunchFlowURLFieldProps): React.JSX.Element {
    +  const formikContext = useFormikContext<LunchFlowSetupIntroValues>();
    +
    +  if (allowedAPIURLs.length > 1) {
    +    const options: SelectOption<string>[] = allowedAPIURLs.map(url => ({ label: url, value: url }));
    +    const value = options.find(option => option.value === formikContext.values.apiURL);
    +    return (
    +      <Select
    +        className={layoutVariants({ width: 'full' })}
    +        label='API URL'
    +        name='apiURL'
    +        onChange={(newValue: SelectOption<string>) => formikContext.setFieldValue('apiURL', newValue.value)}
    +        options={options}
    +        required
    +        value={value}
    +      />
    +    );
    +  }
    +
    +  return (
    +    <FormTextField
    +      className={layoutVariants({ width: 'full' })}
    +      data-1p-ignore
    +      disabled
    +      label='API URL'
    +      name='apiURL'
    +      placeholder='No API URLs configured!'
    +      required
    +      spellCheck='false'
    +      type='url'
    +    />
    +  );
    +}
    
  • interface/src/hooks/useAppConfiguration.ts+1 1 modified
    @@ -23,7 +23,7 @@ export class AppConfiguration {
       iconsEnabled: boolean;
       plaidEnabled: boolean;
       lunchFlowEnabled: boolean;
    -  lunchFlowDefaultAPIURL: string;
    +  lunchFlowAllowedAPIURLs: Array<string>;
       manualEnabled: true;
       uploadsEnabled: boolean;
       release: string | null;
    
  • server/commands/serve.go+15 0 modified
    @@ -23,6 +23,7 @@ import (
     	"github.com/monetr/monetr/server/config"
     	"github.com/monetr/monetr/server/controller"
     	"github.com/monetr/monetr/server/database"
    +	"github.com/monetr/monetr/server/internal/myownsanity"
     	"github.com/monetr/monetr/server/internal/source"
     	"github.com/monetr/monetr/server/jobs"
     	"github.com/monetr/monetr/server/logging"
    @@ -37,6 +38,7 @@ import (
     	"github.com/monetr/monetr/server/stripe_helper"
     	"github.com/monetr/monetr/server/ui"
     	"github.com/monetr/monetr/server/zoneinfo"
    +	"github.com/pkg/errors"
     	"github.com/spf13/cobra"
     	"github.com/spf13/viper"
     )
    @@ -60,6 +62,19 @@ func ServeCommand(parent *cobra.Command) {
     				log.Info("config file loaded", "config", configFileName)
     			}
     
    +			// As soon as we load the config try to validate it. In the future, other
    +			// validation functions can be added here in order to prevent
    +			// misconfiguration. This is done AFTER the logger is loaded so that if
    +			// there are validation functions in the future that require a logger in
    +			// order to present warnings then that can be done at the configuration
    +			// code level.
    +			if err := myownsanity.FirstError(
    +				configuration.LunchFlow.ValidateConfig(),
    +			); err != nil {
    +				return errors.Wrap(err, "there are configuration problems")
    +			}
    +
    +			// TODO Move this to a configuration validation function
     			if configuration.ReCAPTCHA.Enabled {
     				log.Warn("DEPRECATION WARNING: ReCAPTCHA will be removed in a future release. If you are currently using it then please comment on the issue on GitHub. It is recommended to instead rate limit monetr authentication endpoints instead of using a captcha at this time.",
     					"issueUrl", "https://github.com/monetr/monetr/issues/2979",
    
  • server/config/configuration.go+1 0 modified
    @@ -340,6 +340,7 @@ func setupDefaults(v *viper.Viper) {
     	v.SetDefault("Logging.Level", LogLevel) // Info
     	// Lunch Flow is enabled by default for self-hosted deployments!
     	v.SetDefault("LunchFlow.Enabled", true)
    +	v.SetDefault("LunchFlow.AllowedApiUrls", []string{DefaultLunchFlowAPIURL})
     	v.SetDefault("KeyManagement.Provider", "plaintext")
     	v.SetDefault("Plaid.Enabled", true)
     	v.SetDefault("Plaid.CountryCodes", []plaid.CountryCode{plaid.COUNTRYCODE_US})
    
  • server/config/lunch_flow.go+79 0 modified
    @@ -1,8 +1,87 @@
     package config
     
    +import (
    +	"net/url"
    +	"strings"
    +
    +	"github.com/pkg/errors"
    +)
    +
    +const DefaultLunchFlowAPIURL = "https://lunchflow.app/api/v1"
    +
     type LunchFlow struct {
     	// Enabled just determines whether or not Lunch Flow will be an option to
     	// configure in the UI. This defaults to true as it requires no additional
     	// configuration here for self-hosted users.
     	Enabled bool `yaml:"enabled"`
    +	// AllowedApiUrls is the set of Lunch Flow API URLs this deployment is
    +	// permitted to contact. Comparison is exact-string.
    +	AllowedApiUrls []string `yaml:"allowedApiUrls"`
    +}
    +
    +// ValidateConfig can be called at startup in order to catch problems with the
    +// configuration early on. If lunch flow is not enabled then this is a no-op, if
    +// lunch flow is enabled and there are allowed URLs specified; then this will
    +// validate that those URLs specified are all valid.
    +func (l LunchFlow) ValidateConfig() error {
    +	if !l.IsEnabled() {
    +		return nil
    +	}
    +	for _, allowed := range l.AllowedApiUrls {
    +		parsed, err := url.Parse(allowed)
    +		if err != nil {
    +			return errors.Wrapf(err, "configured Lunch Flow url (%s) is not valid", allowed)
    +		}
    +
    +		// Do not allow query parameters in the URL as these will be removed when
    +		// requests are made!
    +		if len(parsed.Query()) > 0 {
    +			return errors.Errorf("Lunch Flow url (%s) cannot contain query parameters", allowed)
    +		}
    +
    +		// Require a scheme to be specified
    +		switch strings.ToLower(parsed.Scheme) {
    +		case "http", "https":
    +			// These are considered valid!
    +		default:
    +			// Any other scheme is not considered valid here!
    +			return errors.Errorf("Lunch Flow url (%s) must use an http or https scheme", allowed)
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +// IsEnabled only returns true if the lunch flow integration is enabled AND when
    +// there is at least one allowed API URLs configured.
    +func (l LunchFlow) IsEnabled() bool {
    +	return l.Enabled && len(l.AllowedApiUrls) > 0
    +}
    +
    +// IsAllowedApiUrl returns true when the provided URL matches one of the
    +// configured allowed Lunch Flow API URLs. Matching is done after both the input
    +// URL and the allowed URLs are parsed via [url.Parse] in order to ensure
    +// correctness.
    +func (l LunchFlow) IsAllowedApiUrl(input string) bool {
    +	inputUrl, err := url.Parse(input)
    +	if err != nil {
    +		return false
    +	}
    +
    +	for _, allowed := range l.AllowedApiUrls {
    +		allowedUrl, err := url.Parse(allowed)
    +		if err != nil {
    +			// If an allowed URL in the configuration is not even considered a valid
    +			// url then discard it. It will not be considered valid in the http client
    +			// anyway.
    +			continue
    +		}
    +		// Urls must be equal AFTER parsing, the [url.Parse] function does some
    +		// transformations here that are considered reasonable. Such as converting
    +		// the scheme to be lowercase.
    +		if allowedUrl.String() == inputUrl.String() {
    +			return true
    +		}
    +	}
    +	return false
     }
    
  • server/config/lunch_flow_test.go+96 0 added
    @@ -0,0 +1,96 @@
    +package config_test
    +
    +import (
    +	"testing"
    +
    +	"github.com/monetr/monetr/server/config"
    +	"github.com/stretchr/testify/assert"
    +)
    +
    +func TestLunchFlow_ValidateConfig(t *testing.T) {
    +	t.Run("disabled is a no-op", func(t *testing.T) {
    +		configuration := config.LunchFlow{
    +			Enabled:        false,
    +			AllowedApiUrls: []string{"not a valid url"},
    +		}
    +		assert.NoError(t, configuration.ValidateConfig())
    +	})
    +
    +	t.Run("no allowed urls is a no-op", func(t *testing.T) {
    +		configuration := config.LunchFlow{
    +			Enabled: true,
    +		}
    +		assert.NoError(t, configuration.ValidateConfig())
    +	})
    +
    +	t.Run("valid urls are accepted", func(t *testing.T) {
    +		configuration := config.LunchFlow{
    +			Enabled: true,
    +			AllowedApiUrls: []string{
    +				"https://lunchflow.app/api/v1",
    +				"http://lunchflow.app/api/v1",
    +			},
    +		}
    +		assert.NoError(t, configuration.ValidateConfig())
    +	})
    +
    +	t.Run("unparseable url is rejected", func(t *testing.T) {
    +		configuration := config.LunchFlow{
    +			Enabled:        true,
    +			AllowedApiUrls: []string{"https://lunchflow.app/%zz"},
    +		}
    +		assert.EqualError(t, configuration.ValidateConfig(), `configured Lunch Flow url (https://lunchflow.app/%zz) is not valid: parse "https://lunchflow.app/%zz": invalid URL escape "%zz"`)
    +	})
    +
    +	t.Run("url with query parameters is rejected", func(t *testing.T) {
    +		configuration := config.LunchFlow{
    +			Enabled:        true,
    +			AllowedApiUrls: []string{"https://lunchflow.app/api/v1?token=secret"},
    +		}
    +		assert.EqualError(t, configuration.ValidateConfig(), "Lunch Flow url (https://lunchflow.app/api/v1?token=secret) cannot contain query parameters")
    +	})
    +
    +	t.Run("invalid url in config", func(t *testing.T) {
    +		configuration := config.LunchFlow{
    +			Enabled:        true,
    +			AllowedApiUrls: []string{"example.com"},
    +		}
    +		assert.EqualError(t, configuration.ValidateConfig(), "Lunch Flow url (example.com) must use an http or https scheme")
    +	})
    +}
    +
    +func TestLunchFlow_IsAllowedApiUrl(t *testing.T) {
    +	t.Run("exact match is allowed", func(t *testing.T) {
    +		configuration := config.LunchFlow{
    +			AllowedApiUrls: []string{"https://lunchflow.app/api/v1"},
    +		}
    +		assert.True(t, configuration.IsAllowedApiUrl("https://lunchflow.app/api/v1"))
    +	})
    +
    +	t.Run("mismatch is rejected", func(t *testing.T) {
    +		configuration := config.LunchFlow{
    +			AllowedApiUrls: []string{"https://lunchflow.app/api/v1"},
    +		}
    +		assert.False(t, configuration.IsAllowedApiUrl("http://169.254.169.254/latest/meta-data"))
    +		assert.False(t, configuration.IsAllowedApiUrl("http://127.0.0.1"))
    +		assert.False(t, configuration.IsAllowedApiUrl("https://lunchflow.app/api/v2"))
    +	})
    +
    +	t.Run("empty list rejects everything", func(t *testing.T) {
    +		configuration := config.LunchFlow{}
    +		assert.False(t, configuration.IsAllowedApiUrl("https://lunchflow.app/api/v1"))
    +		assert.False(t, configuration.IsAllowedApiUrl(""))
    +	})
    +
    +	t.Run("multiple entries match any of them", func(t *testing.T) {
    +		configuration := config.LunchFlow{
    +			AllowedApiUrls: []string{
    +				"https://lunchflow.app/api/v1",
    +				"https://lunchflow.compatible.app/api/v1",
    +			},
    +		}
    +		assert.True(t, configuration.IsAllowedApiUrl("https://lunchflow.app/api/v1"))
    +		assert.True(t, configuration.IsAllowedApiUrl("https://lunchflow.compatible.app/api/v1"))
    +		assert.False(t, configuration.IsAllowedApiUrl("https://other.lunchflow.app/api/v1"))
    +	})
    +}
    
  • server/controller/config.go+25 26 modified
    @@ -5,7 +5,6 @@ import (
     
     	"github.com/labstack/echo/v4"
     	"github.com/monetr/monetr/server/build"
    -	"github.com/monetr/monetr/server/datasources/lunch_flow"
     	"github.com/monetr/monetr/server/icons"
     )
     
    @@ -14,29 +13,29 @@ func (c *Controller) configEndpoint(ctx echo.Context) error {
     		Price int64 `json:"price"`
     	}
     	var configuration struct {
    -		RequireLegalName       bool         `json:"requireLegalName"`
    -		RequirePhoneNumber     bool         `json:"requirePhoneNumber"`
    -		VerifyLogin            bool         `json:"verifyLogin"`
    -		VerifyRegister         bool         `json:"verifyRegister"`
    -		VerifyEmailAddress     bool         `json:"verifyEmailAddress"`
    -		VerifyForgotPassword   bool         `json:"verifyForgotPassword"`
    -		ReCAPTCHAKey           string       `json:"ReCAPTCHAKey,omitempty"`
    -		AllowSignUp            bool         `json:"allowSignUp"`
    -		AllowForgotPassword    bool         `json:"allowForgotPassword"`
    -		LongPollPlaidSetup     bool         `json:"longPollPlaidSetup"`
    -		RequireBetaCode        bool         `json:"requireBetaCode"`
    -		InitialPlan            *InitialPlan `json:"initialPlan"`
    -		BillingEnabled         bool         `json:"billingEnabled"`
    -		IconsEnabled           bool         `json:"iconsEnabled"`
    -		PlaidEnabled           bool         `json:"plaidEnabled"`
    -		LunchFlowEnabled       bool         `json:"lunchFlowEnabled"`
    -		LunchFlowDefaultAPIURL string       `json:"lunchFlowDefaultAPIURL"`
    -		ManualEnabled          bool         `json:"manualEnabled"`
    -		UploadsEnabled         bool         `json:"uploadsEnabled"`
    -		Release                string       `json:"release"`
    -		Revision               string       `json:"revision"`
    -		BuildType              string       `json:"buildType"`
    -		BuildTime              string       `json:"buildTime"`
    +		RequireLegalName        bool         `json:"requireLegalName"`
    +		RequirePhoneNumber      bool         `json:"requirePhoneNumber"`
    +		VerifyLogin             bool         `json:"verifyLogin"`
    +		VerifyRegister          bool         `json:"verifyRegister"`
    +		VerifyEmailAddress      bool         `json:"verifyEmailAddress"`
    +		VerifyForgotPassword    bool         `json:"verifyForgotPassword"`
    +		ReCAPTCHAKey            string       `json:"ReCAPTCHAKey,omitempty"`
    +		AllowSignUp             bool         `json:"allowSignUp"`
    +		AllowForgotPassword     bool         `json:"allowForgotPassword"`
    +		LongPollPlaidSetup      bool         `json:"longPollPlaidSetup"`
    +		RequireBetaCode         bool         `json:"requireBetaCode"`
    +		InitialPlan             *InitialPlan `json:"initialPlan"`
    +		BillingEnabled          bool         `json:"billingEnabled"`
    +		IconsEnabled            bool         `json:"iconsEnabled"`
    +		PlaidEnabled            bool         `json:"plaidEnabled"`
    +		LunchFlowEnabled        bool         `json:"lunchFlowEnabled"`
    +		LunchFlowAllowedAPIURLs []string     `json:"lunchFlowAllowedAPIURLs"`
    +		ManualEnabled           bool         `json:"manualEnabled"`
    +		UploadsEnabled          bool         `json:"uploadsEnabled"`
    +		Release                 string       `json:"release"`
    +		Revision                string       `json:"revision"`
    +		BuildType               string       `json:"buildType"`
    +		BuildTime               string       `json:"buildTime"`
     	}
     
     	configuration.Release = build.Release
    @@ -93,8 +92,8 @@ func (c *Controller) configEndpoint(ctx echo.Context) error {
     	configuration.ManualEnabled = true
     	configuration.UploadsEnabled = c.Configuration.Storage.Enabled
     
    -	configuration.LunchFlowEnabled = c.Configuration.LunchFlow.Enabled
    -	configuration.LunchFlowDefaultAPIURL = lunch_flow.DefaultAPIURL
    +	configuration.LunchFlowEnabled = c.Configuration.LunchFlow.IsEnabled()
    +	configuration.LunchFlowAllowedAPIURLs = c.Configuration.LunchFlow.AllowedApiUrls
     
     	return ctx.JSON(http.StatusOK, configuration)
     }
    
  • server/controller/lunch_flow.go+5 0 modified
    @@ -100,6 +100,10 @@ func (c *Controller) parsePostLunchFlowLinkRequest(
     
     					return true
     				}, "Lunch Flow API URL must be a full valid URL"),
    +				validation.NewStringRule(
    +					c.Configuration.LunchFlow.IsAllowedApiUrl,
    +					"Lunch Flow API URL is not valid or is not in the configured allowlist",
    +				),
     			).Required(validators.Require),
     			validation.Key(
     				"apiKey",
    @@ -205,6 +209,7 @@ func (c *Controller) postLunchFlowLinkBankAccountsRefresh(ctx echo.Context) erro
     		log,
     		link.ApiUrl,
     		secret.Value,
    +		c.Configuration.LunchFlow,
     	)
     	if err != nil {
     		return c.wrapAndReturnError(
    
  • server/controller/lunch_flow_test.go+44 0 modified
    @@ -100,6 +100,50 @@ func TestPostLunchFlowLink(t *testing.T) {
     		response.JSON().Path("$.problems.lunchFlowURL").String().IsEqual("Lunch Flow API URL must be a full valid URL")
     	})
     
    +	t.Run("URL not in allowlist", func(t *testing.T) {
    +		for _, disallowed := range []string{
    +			"http://169.254.169.254/latest/meta-data",
    +			"http://127.0.0.1",
    +			"http://localhost",
    +			"https://attacker.example.com/api/v1",
    +		} {
    +			_, e := NewTestApplication(t)
    +			token := GivenIHaveToken(t, e)
    +			response := e.POST("/api/lunch_flow/link").
    +				WithCookie(TestCookieName, token).
    +				WithJSON(map[string]any{
    +					"name":         "Not Allowed",
    +					"lunchFlowURL": disallowed,
    +					"apiKey":       "foobar",
    +				}).
    +				Expect()
    +
    +			response.Status(http.StatusBadRequest)
    +			response.JSON().Path("$.error").String().IsEqual("Invalid request")
    +			response.JSON().Path("$.problems.lunchFlowURL").String().IsEqual("Lunch Flow API URL is not valid or is not in the configured allowlist")
    +		}
    +	})
    +
    +	t.Run("allowlist with multiple entries accepts any", func(t *testing.T) {
    +		config := NewTestApplicationConfig(t)
    +		config.LunchFlow.AllowedApiUrls = []string{
    +			"https://lunchflow.app/api/v1",
    +			"https://lunchflow.compatible.app/api/v1",
    +		}
    +		_, e := NewTestApplicationWithConfig(t, config)
    +		token := GivenIHaveToken(t, e)
    +
    +		response := e.POST("/api/lunch_flow/link").
    +			WithCookie(TestCookieName, token).
    +			WithJSON(map[string]any{
    +				"name":         "Staging",
    +				"lunchFlowURL": "https://lunchflow.compatible.app/api/v1",
    +				"apiKey":       "foobar",
    +			}).
    +			Expect()
    +		response.Status(http.StatusOK)
    +	})
    +
     	t.Run("invalid api key", func(t *testing.T) {
     		_, e := NewTestApplication(t)
     		token := GivenIHaveToken(t, e)
    
  • server/datasources/lunch_flow/client.go+25 4 modified
    @@ -11,15 +11,20 @@ import (
     	"path"
     	"time"
     
    +	"github.com/monetr/monetr/server/config"
     	"github.com/monetr/monetr/server/crumbs"
     	"github.com/monetr/monetr/server/round"
     	"github.com/pkg/errors"
     )
     
    -const DefaultAPIURL = "https://lunchflow.app/api/v1"
    -
     const DateFormat = "2006-01-02"
     
    +// maxResponseBodySize caps how much of an upstream response body we will read.
    +// This is defense against a hostile or compromised upstream streaming an
    +// unbounded response to exhaust memory. 10mb is generous for realistic account
    +// and transaction payloads while bounding worst case allocation.
    +const maxResponseBodySize = 10 * 1024 * 1024
    +
     type LunchFlowAccountId = json.Number
     
     type Account struct {
    @@ -64,7 +69,22 @@ func NewLunchFlowClient(
     	log *slog.Logger,
     	apiUrl string,
     	accessToken string,
    +	configuration config.LunchFlow,
     ) (LunchFlowClient, error) {
    +	if !configuration.Enabled {
    +		log.Error("lunch flow is not enabled on this server but the client is being instantiated!",
    +			"bug", true,
    +		)
    +		return nil, errors.New("Lunch Flow is not enabled on this server")
    +	}
    +
    +	if !configuration.IsAllowedApiUrl(apiUrl) {
    +		log.Warn("rejected Lunch Flow API URL that is not in the configured allowlist, please update your configuration if this url is valid!",
    +			"apiUrl", apiUrl,
    +		)
    +		return nil, errors.New("Lunch Flow API URL is not in the configured allowlist")
    +	}
    +
     	parsedUrl, err := url.Parse(apiUrl)
     	if err != nil {
     		return nil, errors.WithStack(err)
    @@ -131,12 +151,13 @@ func (l *lunchFlowClient) doRequest(ctx context.Context, relativePath string, re
     	}
     	defer response.Body.Close()
     
    +	body := io.LimitReader(response.Body, maxResponseBodySize)
     	if response.StatusCode != http.StatusOK {
    -		bodyStr, _ := io.ReadAll(response.Body)
    +		bodyStr, _ := io.ReadAll(body)
     		return errors.Errorf("Lunch Flow request failed %s [%d]: %s", requestUrl, response.StatusCode, string(bodyStr))
     	}
     
    -	if err := json.NewDecoder(response.Body).Decode(result); err != nil {
    +	if err := json.NewDecoder(body).Decode(result); err != nil {
     		return errors.Wrapf(err, "failed to decode response for request %s [%d]", requestUrl, response.StatusCode)
     	}
     
    
  • server/datasources/lunch_flow/client_test.go+75 8 modified
    @@ -4,6 +4,7 @@ import (
     	"testing"
     
     	"github.com/jarcoal/httpmock"
    +	"github.com/monetr/monetr/server/config"
     	"github.com/monetr/monetr/server/datasources/lunch_flow"
     	"github.com/monetr/monetr/server/internal/mock_lunch_flow"
     	"github.com/monetr/monetr/server/internal/testutils"
    @@ -13,7 +14,7 @@ import (
     func TestLunchFlowClient_GetAccounts(t *testing.T) {
     	t.Run("happy path, retrieve a few accounts", func(t *testing.T) {
     		httpmock.Activate()
    -		defer httpmock.Deactivate()
    +		defer httpmock.DeactivateAndReset()
     
     		accountOne := lunch_flow.Account{
     			Id:              "1234",
    @@ -40,8 +41,12 @@ func TestLunchFlowClient_GetAccounts(t *testing.T) {
     
     		client, err := lunch_flow.NewLunchFlowClient(
     			log,
    -			lunch_flow.DefaultAPIURL,
    +			config.DefaultLunchFlowAPIURL,
     			"bogus-token",
    +			config.LunchFlow{
    +				Enabled:        true,
    +				AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
    +			},
     		)
     		assert.NoError(t, err, "must not return an error creating the client")
     		assert.NotNil(t, client, "client must have a value")
    @@ -60,16 +65,20 @@ func TestLunchFlowClient_GetAccounts(t *testing.T) {
     
     	t.Run("fail to retrieve accounts", func(t *testing.T) {
     		httpmock.Activate()
    -		defer httpmock.Deactivate()
    +		defer httpmock.DeactivateAndReset()
     
     		mock_lunch_flow.MockFetchAccountsError(t)
     
     		log := testutils.GetLog(t)
     
     		client, err := lunch_flow.NewLunchFlowClient(
     			log,
    -			lunch_flow.DefaultAPIURL,
    +			config.DefaultLunchFlowAPIURL,
     			"bogus-token",
    +			config.LunchFlow{
    +				Enabled:        true,
    +				AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
    +			},
     		)
     		assert.NoError(t, err, "must not return an error creating the client")
     		assert.NotNil(t, client, "client must have a value")
    @@ -84,10 +93,60 @@ func TestLunchFlowClient_GetAccounts(t *testing.T) {
     	})
     }
     
    +func TestLunchFlowClient_Constructor(t *testing.T) {
    +	t.Run("rejects URL that is not in the allowlist", func(t *testing.T) {
    +		log := testutils.GetLog(t)
    +
    +		client, err := lunch_flow.NewLunchFlowClient(
    +			log,
    +			"http://169.254.169.254/latest/meta-data",
    +			"bogus-token",
    +			config.LunchFlow{
    +				Enabled:        true,
    +				AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
    +			},
    +		)
    +		assert.EqualError(t, err, "Lunch Flow API URL is not in the configured allowlist")
    +		assert.Nil(t, client, "client must be nil on rejection")
    +	})
    +
    +	t.Run("rejects when allowlist is empty", func(t *testing.T) {
    +		log := testutils.GetLog(t)
    +
    +		client, err := lunch_flow.NewLunchFlowClient(
    +			log,
    +			config.DefaultLunchFlowAPIURL,
    +			"bogus-token",
    +			config.LunchFlow{
    +				Enabled:        true,
    +				AllowedApiUrls: []string{},
    +			},
    +		)
    +		assert.EqualError(t, err, "Lunch Flow API URL is not in the configured allowlist")
    +		assert.Nil(t, client)
    +	})
    +
    +	t.Run("accepts URL that matches an allowlist entry", func(t *testing.T) {
    +		log := testutils.GetLog(t)
    +
    +		client, err := lunch_flow.NewLunchFlowClient(
    +			log,
    +			config.DefaultLunchFlowAPIURL,
    +			"bogus-token",
    +			config.LunchFlow{
    +				Enabled:        true,
    +				AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
    +			},
    +		)
    +		assert.NoError(t, err, "must accept an allowlisted URL")
    +		assert.NotNil(t, client, "client must be created")
    +	})
    +}
    +
     func TestLunchFlowClient_GetBalance(t *testing.T) {
     	t.Run("happy path read balance", func(t *testing.T) {
     		httpmock.Activate()
    -		defer httpmock.Deactivate()
    +		defer httpmock.DeactivateAndReset()
     
     		expectedBalance := lunch_flow.Balance{
     			Amount:   "1234.56",
    @@ -99,8 +158,12 @@ func TestLunchFlowClient_GetBalance(t *testing.T) {
     
     		client, err := lunch_flow.NewLunchFlowClient(
     			log,
    -			lunch_flow.DefaultAPIURL,
    +			config.DefaultLunchFlowAPIURL,
     			"bogus-token",
    +			config.LunchFlow{
    +				Enabled:        true,
    +				AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
    +			},
     		)
     		assert.NoError(t, err, "must not return an error creating the client")
     		assert.NotNil(t, client, "client must have a value")
    @@ -117,16 +180,20 @@ func TestLunchFlowClient_GetBalance(t *testing.T) {
     
     	t.Run("fails to read balance", func(t *testing.T) {
     		httpmock.Activate()
    -		defer httpmock.Deactivate()
    +		defer httpmock.DeactivateAndReset()
     
     		mock_lunch_flow.MockFetchBalanceError(t, "1234")
     
     		log := testutils.GetLog(t)
     
     		client, err := lunch_flow.NewLunchFlowClient(
     			log,
    -			lunch_flow.DefaultAPIURL,
    +			config.DefaultLunchFlowAPIURL,
     			"bogus-token",
    +			config.LunchFlow{
    +				Enabled:        true,
    +				AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
    +			},
     		)
     		assert.NoError(t, err, "must not return an error creating the client")
     		assert.NotNil(t, client, "client must have a value")
    
  • server/datasources/lunch_flow/lunch_flow_jobs/cleanup_lunch_flow_test.go+4 5 modified
    @@ -6,7 +6,6 @@ import (
     
     	"github.com/benbjohnson/clock"
     	"github.com/monetr/monetr/server/config"
    -	"github.com/monetr/monetr/server/datasources/lunch_flow"
     	"github.com/monetr/monetr/server/datasources/lunch_flow/lunch_flow_jobs"
     	"github.com/monetr/monetr/server/internal/fixtures"
     	"github.com/monetr/monetr/server/internal/mockgen"
    @@ -90,7 +89,7 @@ func TestCleanupLunchFlowCron(t *testing.T) {
     			AccountId: user.AccountId,
     			SecretId:  secret.SecretId,
     			Name:      "Test Lunch Flow Link",
    -			ApiUrl:    lunch_flow.DefaultAPIURL,
    +			ApiUrl:    config.DefaultLunchFlowAPIURL,
     			Status:    models.LunchFlowLinkStatusPending,
     			CreatedBy: user.UserId,
     		}
    @@ -174,7 +173,7 @@ func TestCleanupLunchFlowCron(t *testing.T) {
     			AccountId: user.AccountId,
     			SecretId:  secret.SecretId,
     			Name:      "Test Lunch Flow Link",
    -			ApiUrl:    lunch_flow.DefaultAPIURL,
    +			ApiUrl:    config.DefaultLunchFlowAPIURL,
     			Status:    models.LunchFlowLinkStatusPending,
     			CreatedBy: user.UserId,
     		}
    @@ -257,7 +256,7 @@ func TestCleanupLunchFlow(t *testing.T) {
     			AccountId: user.AccountId,
     			SecretId:  secret.SecretId,
     			Name:      "Test Lunch Flow Link",
    -			ApiUrl:    lunch_flow.DefaultAPIURL,
    +			ApiUrl:    config.DefaultLunchFlowAPIURL,
     			Status:    models.LunchFlowLinkStatusPending,
     			CreatedBy: user.UserId,
     		}
    @@ -375,7 +374,7 @@ func TestCleanupLunchFlow(t *testing.T) {
     			AccountId: user.AccountId,
     			SecretId:  secret.SecretId,
     			Name:      "Test Lunch Flow Link",
    -			ApiUrl:    lunch_flow.DefaultAPIURL,
    +			ApiUrl:    config.DefaultLunchFlowAPIURL,
     			Status:    models.LunchFlowLinkStatusActive,
     			CreatedBy: user.UserId,
     		}
    
  • server/datasources/lunch_flow/lunch_flow_jobs/sync_lunch_flow.go+1 0 modified
    @@ -213,6 +213,7 @@ func (s *syncLunchFlowContext) setupClient(ctx queue.Context) error {
     			s.log,
     			link.LunchFlowLink.ApiUrl,
     			secret.Value,
    +			ctx.Configuration().LunchFlow,
     		)
     		if err != nil {
     			return errors.Wrap(err, "failed to create Lunch Flow API client")
    
  • server/datasources/lunch_flow/lunch_flow_jobs/sync_lunch_flow_test.go+10 5 modified
    @@ -145,7 +145,8 @@ func TestSyncLunchFlow(t *testing.T) {
     				context.EXPECT().Clock().Return(clock).AnyTimes()
     				context.EXPECT().Configuration().Return(config.Configuration{
     					LunchFlow: config.LunchFlow{
    -						Enabled: true,
    +						Enabled:        true,
    +						AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
     					},
     				}).AnyTimes()
     				context.EXPECT().KMS().Return(kms).AnyTimes()
    @@ -215,7 +216,8 @@ func TestSyncLunchFlow(t *testing.T) {
     				context.EXPECT().Clock().Return(clock).AnyTimes()
     				context.EXPECT().Configuration().Return(config.Configuration{
     					LunchFlow: config.LunchFlow{
    -						Enabled: true,
    +						Enabled:        true,
    +						AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
     					},
     				}).AnyTimes()
     				context.EXPECT().KMS().Return(kms).AnyTimes()
    @@ -331,7 +333,8 @@ func TestSyncLunchFlow(t *testing.T) {
     				context.EXPECT().Clock().Return(clock).AnyTimes()
     				context.EXPECT().Configuration().Return(config.Configuration{
     					LunchFlow: config.LunchFlow{
    -						Enabled: true,
    +						Enabled:        true,
    +						AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
     					},
     				}).AnyTimes()
     				context.EXPECT().KMS().Return(kms).AnyTimes()
    @@ -412,7 +415,8 @@ func TestSyncLunchFlow(t *testing.T) {
     				context.EXPECT().Clock().Return(clock).AnyTimes()
     				context.EXPECT().Configuration().Return(config.Configuration{
     					LunchFlow: config.LunchFlow{
    -						Enabled: true,
    +						Enabled:        true,
    +						AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
     					},
     				}).AnyTimes()
     				context.EXPECT().KMS().Return(kms).AnyTimes()
    @@ -511,7 +515,8 @@ func TestSyncLunchFlow(t *testing.T) {
     				context.EXPECT().Clock().Return(clock).AnyTimes()
     				context.EXPECT().Configuration().Return(config.Configuration{
     					LunchFlow: config.LunchFlow{
    -						Enabled: true,
    +						Enabled:        true,
    +						AllowedApiUrls: []string{config.DefaultLunchFlowAPIURL},
     					},
     				}).AnyTimes()
     				context.EXPECT().KMS().Return(kms).AnyTimes()
    
  • server/internal/fixtures/link.go+2 2 modified
    @@ -6,8 +6,8 @@ import (
     
     	"github.com/benbjohnson/clock"
     	"github.com/brianvoe/gofakeit/v6"
    +	"github.com/monetr/monetr/server/config"
     	"github.com/monetr/monetr/server/consts"
    -	"github.com/monetr/monetr/server/datasources/lunch_flow"
     	"github.com/monetr/monetr/server/internal/testutils"
     	. "github.com/monetr/monetr/server/models"
     	"github.com/monetr/monetr/server/repository"
    @@ -119,7 +119,7 @@ func GivenIHaveALunchFlowLink(t *testing.T, clock clock.Clock, user User) Link {
     		AccountId:            user.AccountId,
     		SecretId:             secret.SecretId,
     		Name:                 fmt.Sprintf("Lunch Flow Budget %s", gofakeit.City()),
    -		ApiUrl:               lunch_flow.DefaultAPIURL,
    +		ApiUrl:               config.DefaultLunchFlowAPIURL,
     		Status:               LunchFlowLinkStatusActive,
     		LastManualSync:       nil,
     		LastSuccessfulUpdate: nil,
    
  • server/internal/mock_lunch_flow/mock.go+2 2 modified
    @@ -7,7 +7,7 @@ import (
     	"testing"
     
     	"github.com/brianvoe/gofakeit/v6"
    -	"github.com/monetr/monetr/server/datasources/lunch_flow"
    +	"github.com/monetr/monetr/server/config"
     	"github.com/stretchr/testify/assert"
     	"github.com/stretchr/testify/require"
     )
    @@ -20,7 +20,7 @@ const (
     
     func Path(t *testing.T, relative string) string {
     	require.NotEmpty(t, relative, "relative url cannot be empty")
    -	parsed, err := url.Parse(lunch_flow.DefaultAPIURL)
    +	parsed, err := url.Parse(config.DefaultLunchFlowAPIURL)
     	require.NoError(t, err, "must be able to parse lunch flow's default base URL")
     	parsed.Path = relative
     	return parsed.String()
    
  • server/internal/testutils/configuration.go+2 1 modified
    @@ -38,7 +38,8 @@ func GetConfig(t *testing.T) config.Configuration {
     		LunchFlow: config.LunchFlow{
     			// By default lunch flow is enabled in tests, disable it to simulate
     			// alternate behaviors.
    -			Enabled: true,
    +			Enabled:        true,
    +			AllowedApiUrls: []string{"https://lunchflow.app/api/v1"},
     		},
     		Plaid: config.Plaid{
     			Enabled:      true,
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.