diff --git a/.bumpy/fix-url-allowed-domains.md b/.bumpy/fix-url-allowed-domains.md new file mode 100644 index 000000000..8cdb4873a --- /dev/null +++ b/.bumpy/fix-url-allowed-domains.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +Fix url(allowedDomains) comma-string membership checks diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index ecf7fb680..966d6c34f 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -404,10 +404,18 @@ const UrlDataType = createEnvGraphDataType( throw new ValidationError('Invalid URL'); } const errors = [] as Array; - if ( - settings?.allowedDomains && !settings.allowedDomains.includes(url.host.toLowerCase()) - ) { - errors.push(new ValidationError(`Domain (${url.host}) is not in allowed list: ${settings.allowedDomains.join(',')}`)); + // allowedDomains may arrive as a comma-string (`"a.com,b.com"`) from schema + // syntax, or as a real array. Normalize before membership checks — string + // `.includes` is substring match and would allow e.g. "ample.com" for "example.com", + // and `.join` on a string throws when building the rejection message. + const allowedDomains = (() => { + const raw = settings?.allowedDomains as Array | string | undefined; + if (!raw) return [] as Array; + const list = Array.isArray(raw) ? raw : String(raw).split(','); + return list.map((d) => d.trim().toLowerCase()).filter(Boolean); + })(); + if (allowedDomains.length && !allowedDomains.includes(url.host.toLowerCase())) { + errors.push(new ValidationError(`Domain (${url.host}) is not in allowed list: ${allowedDomains.join(',')}`)); } if (settings?.noTrailingSlash && val.endsWith('/')) { errors.push(new ValidationError('URL must not have a trailing slash')); diff --git a/packages/varlock/src/env-graph/test/data-types.test.ts b/packages/varlock/src/env-graph/test/data-types.test.ts index 2aac08f93..dac8fa227 100644 --- a/packages/varlock/src/env-graph/test/data-types.test.ts +++ b/packages/varlock/src/env-graph/test/data-types.test.ts @@ -65,6 +65,34 @@ describe('url data type', () => { }); }); + describe('allowedDomains', () => { + it('accepts a host listed in a comma-string allowlist', async () => { + const g = await loadAndResolve(outdent` + # @type=url(allowedDomains="example.com,api.example.com") + MY_URL=https://api.example.com/v1 + `); + expect(g.configSchema.MY_URL.isValid).toBe(true); + }); + + it('rejects a host that is only a substring of an allowlist entry', async () => { + const g = await loadAndResolve(outdent` + # @type=url(allowedDomains="example.com") + MY_URL=https://ample.com/ + `); + expect(g.configSchema.MY_URL.isValid).toBe(false); + expect(g.configSchema.MY_URL.errors[0]?.message).toMatch(/not in allowed list/); + }); + + it('rejects a disallowed host without throwing on the error message', async () => { + const g = await loadAndResolve(outdent` + # @type=url(allowedDomains="example.com") + MY_URL=https://evil.com/ + `); + expect(g.configSchema.MY_URL.isValid).toBe(false); + expect(g.configSchema.MY_URL.errors[0]?.message).toContain('example.com'); + }); + }); + describe('noTrailingSlash', () => { it('accepts url without trailing slash', async () => { const g = await loadAndResolve(outdent`