Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .bumpy/url-allowed-protocols.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
varlock: minor
env-spec-language: minor
---

Added allowed protocol validation for URL values.
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ MY_BOOL=true
### `url`
**Options:**
- `prependHttps` (boolean): Automatically prepend "https://" if no protocol is specified
- `allowedProtocols` (string[]): List of allowed protocols. Protocol names are case-insensitive and can include the trailing colon. If omitted, any valid URL protocol is allowed
- `allowedDomains` (string[]): List of allowed domains
- `noTrailingSlash` (boolean): Disallow a trailing slash on the URL path (except root `/`)
- `matches` (string|RegExp): Regular expression pattern the full URL must match. Use `/pattern/flags` syntax or a quoted string pattern (see [regex-like strings](/reference/functions#regex-like-strings))
Expand All @@ -173,6 +174,9 @@ MY_BOOL=true
# @type=url(prependHttps=true)
MY_URL=example.com/foobar

# @type=url(allowedProtocols=[postgres, postgresql])
DATABASE_URL=postgres://root:password@localhost:5432/local

# @type=url(noTrailingSlash=true, matches=/^https:\/\/api\./)
API_URL=https://api.example.com/v1
```
Expand Down
19 changes: 18 additions & 1 deletion packages/varlock/src/env-graph/lib/data-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ const UrlDataType = createEnvGraphDataType(
(settings?: {
prependHttps?: boolean
allowedDomains?: Array<string>
allowedProtocols?: Array<string>
/** Disallow a trailing slash on the URL path. */
noTrailingSlash?: boolean
/** A regular expression or string pattern that the full URL must match. */
Expand All @@ -393,7 +394,7 @@ const UrlDataType = createEnvGraphDataType(
generatePlaceholder: (seed) => `https://${seed}.invalid/`,
coerce(rawVal) {
const val = coerceToString(rawVal);
if (settings?.prependHttps && !val.startsWith('https://')) return `https://${val}`;
if (settings?.prependHttps && !/^[a-z][a-z\d+.-]*:/i.test(val)) return `https://${val}`;
return val;
},
validate(val) {
Expand All @@ -404,6 +405,22 @@ const UrlDataType = createEnvGraphDataType(
throw new ValidationError('Invalid URL');
}
const errors = [] as Array<ValidationError>;
if (settings?.allowedProtocols) {
if (
!Array.isArray(settings.allowedProtocols)
|| settings.allowedProtocols.some((allowedProtocol) => !_.isString(allowedProtocol))
) {
errors.push(new ValidationError('allowedProtocols must be an array of strings'));
} else {
const protocol = url.protocol.replace(/:$/, '').toLowerCase();
const allowedProtocols = settings.allowedProtocols.map((allowedProtocol) => (
allowedProtocol.replace(/:$/, '').toLowerCase()
));
if (!allowedProtocols.includes(protocol)) {
errors.push(new ValidationError(`Protocol (${protocol}) is not in allowed list: ${settings.allowedProtocols.join(',')}`));
}
}
}
if (
settings?.allowedDomains && !settings.allowedDomains.includes(url.host.toLowerCase())
) {
Expand Down
53 changes: 53 additions & 0 deletions packages/varlock/src/env-graph/test/data-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,59 @@ describe('url data type', () => {
});
});

describe('allowedProtocols', () => {
it('accepts URLs with a listed protocol', async () => {
const g = await loadAndResolve(outdent`
# @type=url(allowedProtocols=[postgres, postgresql])
DATABASE_URL=postgres://root:password@localhost:5432/local
`);
expect(g.configSchema.DATABASE_URL.isValid).toBe(true);
});

it('rejects URLs with an unlisted protocol', async () => {
const g = await loadAndResolve(outdent`
# @type=url(allowedProtocols=[http, https])
DATABASE_URL=postgres://root:password@localhost:5432/local
`);
expect(g.configSchema.DATABASE_URL.isValid).toBe(false);
expect(g.configSchema.DATABASE_URL.validationErrors?.[0]?.message).toContain('Protocol (postgres) is not in allowed list');
});

it('matches protocol names case-insensitively with an optional trailing colon', async () => {
const g = await loadAndResolve(outdent`
# @type=url(allowedProtocols=[POSTGRES:])
DATABASE_URL=postgres://root:password@localhost:5432/local
`);
expect(g.configSchema.DATABASE_URL.isValid).toBe(true);
});

it('accepts any valid URL protocol when omitted', async () => {
const g = await loadAndResolve(outdent`
# @type=url
DATABASE_URL=postgres://root:password@localhost:5432/local
`);
expect(g.configSchema.DATABASE_URL.isValid).toBe(true);
});

it('requires an array of protocols', async () => {
const g = await loadAndResolve(outdent`
# @type=url(allowedProtocols=postgres)
DATABASE_URL=postgres://root:password@localhost:5432/local
`);
expect(g.configSchema.DATABASE_URL.isValid).toBe(false);
expect(g.configSchema.DATABASE_URL.validationErrors?.[0]?.message).toContain('allowedProtocols must be an array of strings');
});

it('does not prepend HTTPS when the URL already has an allowed protocol', async () => {
const g = await loadAndResolve(outdent`
# @type=url(prependHttps=true, allowedProtocols=[postgres])
DATABASE_URL=postgres://root:password@localhost:5432/local
`);
expect(g.configSchema.DATABASE_URL.isValid).toBe(true);
expect(g.configSchema.DATABASE_URL.resolvedValue).toBe('postgres://root:password@localhost:5432/local');
});
});

describe('noTrailingSlash', () => {
it('accepts url without trailing slash', async () => {
const g = await loadAndResolve(outdent`
Expand Down
42 changes: 31 additions & 11 deletions packages/vscode-plugin/src/diagnostics-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@ export function splitCommaSeparatedArgs(input: string) {
continue;
}

if (char === '(') {
if (char === '(' || char === '[') {
depth += 1;
current += char;
continue;
}

if (char === ')') {
if (char === ')' || char === ']') {
depth = Math.max(depth - 1, 0);
current += char;
continue;
Expand All @@ -133,6 +133,22 @@ export function splitEnumArgs(input: string) {
.filter(Boolean);
}

function parseListOption(value: string | boolean | undefined) {
if (typeof value !== 'string') return [];
const trimmedValue = value.trim();
if (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')) {
return splitEnumArgs(trimmedValue.slice(1, -1));
}
return splitEnumArgs(trimmedValue);
}

function parseArrayOption(value: string | boolean | undefined) {
if (typeof value !== 'string') return undefined;
const trimmedValue = value.trim();
if (!trimmedValue.startsWith('[') || !trimmedValue.endsWith(']')) return undefined;
return splitEnumArgs(trimmedValue.slice(1, -1));
}

export function parseBooleanOption(value: string | boolean | undefined) {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
Expand Down Expand Up @@ -345,21 +361,25 @@ function validateNumberValue(value: string, options: TypeInfo['options']) {

function validateUrlValue(value: string, options: TypeInfo['options']) {
const prependHttps = parseBooleanOption(options.prependHttps);
const hasProtocol = /^https?:\/\//i.test(value);

if (prependHttps && hasProtocol) {
return 'URL should omit the protocol when prependHttps=true.';
}
const hasProtocol = /^[a-z][a-z\d+.-]*:/i.test(value);

if (!prependHttps && !hasProtocol) {
return 'URL must include a protocol unless prependHttps=true.';
}

try {
const url = new URL(prependHttps ? `https://${value}` : value);
const allowedDomains = typeof options.allowedDomains === 'string'
? splitEnumArgs(options.allowedDomains)
: [];
const url = new URL(prependHttps && !hasProtocol ? `https://${value}` : value);
const allowedDomains = parseListOption(options.allowedDomains);
const allowedProtocols = parseArrayOption(options.allowedProtocols);

if (options.allowedProtocols !== undefined) {
if (!allowedProtocols) return '`allowedProtocols` must be an array of strings.';
const normalizedProtocols = allowedProtocols
.map((protocol) => protocol.replace(/:$/, '').toLowerCase());
if (!normalizedProtocols.includes(url.protocol.replace(/:$/, '').toLowerCase())) {
return `URL protocol must be one of: ${normalizedProtocols.join(', ')}.`;
}
}

if (allowedDomains.length > 0 && !allowedDomains.includes(url.host.toLowerCase())) {
return `URL host must be one of: ${allowedDomains.join(', ')}.`;
Expand Down
3 changes: 2 additions & 1 deletion packages/vscode-plugin/src/intellisense-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,11 +319,12 @@ export const DATA_TYPES: Array<DataTypeInfo> = [
},
{
name: 'url',
summary: 'URL with optional HTTPS prepending, allowed-domain checks, trailing-slash enforcement, and regex matching.',
summary: 'URL with optional HTTPS prepending, protocol and domain checks, trailing-slash enforcement, and regex matching.',
documentation: 'Example: `@type=url(prependHttps=true)`.',
insertText: 'url',
optionSnippets: [
{ name: 'prependHttps', insertText: `prependHttps=${booleanChoiceSnippet()}`, documentation: 'Automatically add `https://` when missing.' },
{ name: 'allowedProtocols', insertText: 'allowedProtocols=[${1:http}, ${2:https}]', documentation: 'Restrict the URL to an allowed protocol list.' },
{ name: 'allowedDomains', insertText: 'allowedDomains=${1:"example.com"}', documentation: 'Restrict the URL host to an allowed domain list.' },
{ name: 'noTrailingSlash', insertText: `noTrailingSlash=${booleanChoiceSnippet()}`, documentation: 'Disallow a trailing slash on the URL path.' },
{ name: 'matches', insertText: 'matches=${1:"pattern"}', documentation: 'A regular expression that the full URL must match.' },
Expand Down
50 changes: 48 additions & 2 deletions packages/vscode-plugin/test/diagnostics-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('diagnostics-core', () => {

it('reads type info from the comment block above an item', () => {
const document = createLineDocument([
'# @required @type=url(prependHttps=true, allowedDomains="example.com,api.example.com")',
'# @required @type=url(prependHttps=true, allowedDomains="example.com,api.example.com", allowedProtocols=[http, https])',
'API_URL=example.com',
]);

Expand All @@ -112,6 +112,7 @@ describe('diagnostics-core', () => {
options: {
prependHttps: 'true',
allowedDomains: 'example.com,api.example.com',
allowedProtocols: '[http, https]',
},
});
});
Expand Down Expand Up @@ -155,7 +156,7 @@ describe('diagnostics-core', () => {
},
'https://example.com',
),
).toBe('URL should omit the protocol when prependHttps=true.');
).toBeUndefined();

expect(
validateStaticValue(
Expand All @@ -180,6 +181,51 @@ describe('diagnostics-core', () => {
).toBe('URL must include a protocol unless prependHttps=true.');
});

it('validates allowedProtocols url option', () => {
const typeInfo = {
name: 'url',
args: [],
options: { allowedProtocols: '[postgres, postgresql:]' },
};

expect(validateStaticValue(typeInfo, 'postgres://localhost/database')).toBeUndefined();
expect(validateStaticValue(typeInfo, 'POSTGRESQL://localhost/database')).toBeUndefined();
expect(validateStaticValue(typeInfo, 'https://example.com')).toBe(
'URL protocol must be one of: postgres, postgresql.',
);
});

it('requires allowedProtocols to use array syntax', () => {
expect(
validateStaticValue(
{ name: 'url', args: [], options: { allowedProtocols: 'postgres' } },
'postgres://localhost/database',
),
).toBe('`allowedProtocols` must be an array of strings.');
});

it('does not prepend HTTPS when the URL already has an allowed protocol', () => {
expect(
validateStaticValue(
{
name: 'url',
args: [],
options: { prependHttps: 'true', allowedProtocols: '[postgres]' },
},
'postgres://localhost/database',
),
).toBeUndefined();
});

it('accepts any valid URL protocol when allowedProtocols is omitted', () => {
expect(
validateStaticValue(
{ name: 'url', args: [], options: {} },
'postgres://localhost/database',
),
).toBeUndefined();
});

it('validates noTrailingSlash url option', () => {
expect(
validateStaticValue(
Expand Down
Loading