diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 0fdda11ec..0479ad951 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -16,13 +16,12 @@ const path = require('path'); const { parseDataFromResponse } = require('../utils/common'); const { getCookieStringForUrl, saveCookies } = require('../utils/cookies'); const { createFormData } = require('../utils/form-data'); -const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const { NtlmClient } = require('axios-ntlm'); const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2, applyOAuth1ToRequest } = require('@usebruno/requests'); const { getCACertificates, transformProxyConfig } = require('@usebruno/requests'); const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2'); const tokenStore = require('../store/tokenStore'); -const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils; +const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData, extractBoundaryFromContentType, hasExplicitScheme } = require('@usebruno/common').utils; const onConsoleLog = (type, args) => { console[type](...args); @@ -344,7 +343,7 @@ const runSingleRequest = async function ( request.url = encodeUrl(request.url); } - if (!protocolRegex.test(request.url)) { + if (!hasExplicitScheme(request.url)) { request.url = `http://${request.url}`; } diff --git a/packages/bruno-cli/tests/runner/response-fields.spec.js b/packages/bruno-cli/tests/runner/response-fields.spec.js index f3314fdcf..a10a4408f 100644 --- a/packages/bruno-cli/tests/runner/response-fields.spec.js +++ b/packages/bruno-cli/tests/runner/response-fields.spec.js @@ -68,14 +68,18 @@ jest.mock('../../src/store/tokenStore', () => ({ // Default: no prompt variables detected const mockExtractPromptVariables = jest.fn(() => []); -jest.mock('@usebruno/common', () => ({ - utils: { - encodeUrl: jest.fn((u) => u), - buildFormUrlEncodedPayload: jest.fn(), - extractPromptVariables: mockExtractPromptVariables, - isFormData: jest.fn(() => false) - } -})); +jest.mock('@usebruno/common', () => { + const ogUtils = jest.requireActual('@usebruno/common').utils; + return { + utils: { + encodeUrl: jest.fn((u) => u), + buildFormUrlEncodedPayload: jest.fn(), + extractPromptVariables: mockExtractPromptVariables, + isFormData: jest.fn(() => false), + hasExplicitScheme: ogUtils.hasExplicitScheme + } + }; +}); const prepareRequest = require('../../src/runner/prepare-request'); const { makeAxiosInstance } = require('../../src/utils/axios-instance'); diff --git a/packages/bruno-common/src/utils/index.ts b/packages/bruno-common/src/utils/index.ts index 3c76116dd..7e481a6aa 100644 --- a/packages/bruno-common/src/utils/index.ts +++ b/packages/bruno-common/src/utils/index.ts @@ -1,4 +1,5 @@ export { + hasExplicitScheme, encodeUrl, parseQueryParams, buildQueryString, diff --git a/packages/bruno-common/src/utils/url/index.spec.ts b/packages/bruno-common/src/utils/url/index.spec.ts index 178297b83..69e0b80d1 100644 --- a/packages/bruno-common/src/utils/url/index.spec.ts +++ b/packages/bruno-common/src/utils/url/index.spec.ts @@ -1,4 +1,4 @@ -import { encodeUrl, parseQueryParams, buildQueryString } from './index'; +import { encodeUrl, parseQueryParams, buildQueryString, hasExplicitScheme } from './index'; describe('encodeUrl', () => { describe('basic functionality', () => { @@ -260,3 +260,49 @@ describe('buildQueryString', () => { expect(result).toBe('seat=&table=2'); }); }); + +describe('hasExplicitScheme', () => { + // should return false + const noScheme: [string, string][] = [ + ['bare hostname', 'test-domain'], + ['localhost', 'localhost'], + ['localhost:port (key regression)', 'localhost:8080'], + ['localhost:port/path', 'localhost:8080/path'], + ['127.0.0.1:port', '127.0.0.1:3000'], + ['bare IP', '192.168.1.1'], + ['IP:port', '192.168.1.1:8080'], + ['hostname with path', 'example.com/api/v1'] + ]; + + for (const [label, url] of noScheme) { + it(`false (no explicit scheme) — ${label}`, () => { + expect(hasExplicitScheme(url)).toBe(false); + }); + } + + // should return true + const withScheme: [string, string][] = [ + ['http://', 'http://example.com'], + ['https://', 'https://example.com'], + ['ftp://', 'ftp://test-domain'], + ['ws://', 'ws://example.com/socket'], + ['wss://', 'wss://example.com/socket'], + ['custom scheme', 'myapp://deep-link'] + ]; + + for (const [label, url] of withScheme) { + it(`true (has explicit scheme) — ${label}`, () => { + expect(hasExplicitScheme(url)).toBe(true); + }); + } + + it('{{baseUrl}}/api — no scheme injection for template variables', async () => { + const url = '{{baseUrl}}/api/v1'; + expect(hasExplicitScheme(url)).toBe(false); + }); + + it('{{baseUrl}} alone — no scheme injection for template variables', async () => { + const url = '{{baseUrl}}'; + expect(hasExplicitScheme(url)).toBe(false); + }); +}); diff --git a/packages/bruno-common/src/utils/url/index.ts b/packages/bruno-common/src/utils/url/index.ts index 466dca59f..dae5e65cb 100644 --- a/packages/bruno-common/src/utils/url/index.ts +++ b/packages/bruno-common/src/utils/url/index.ts @@ -1,3 +1,45 @@ +/** + * Returns true when `url` already carries an explicit network scheme. + * + * Per the WHATWG URL Standard, all network-fetch schemes (http, https, ftp, + * ws, wss, file) require "://" — the authority component is mandatory. + * This means "localhost:8080" is NOT a scheme: the colon separates host from + * port, so callers should prepend "http://" to it. + * + * The scheme character set (ASCII alpha/digit/+/-/.) follows the WHATWG URL + * scheme-state parser, which accepts the same characters as all major browsers. + * @see https://url.spec.whatwg.org/#scheme-state + * + * @example + * hasExplicitScheme('https://example.com') // true + * hasExplicitScheme('ftp://files.example') // true + * hasExplicitScheme('localhost:8080') // false — port colon, not scheme + * hasExplicitScheme('example.com/api') // false — no scheme at all + */ +function hasExplicitScheme(url: string): boolean { + // All WHATWG network schemes require authority ("://"). + const authorityStart = url.indexOf('://'); + if (authorityStart < 1) return false; + + const scheme = url.slice(0, authorityStart); + + // WHATWG URL scheme-state: first character must be ASCII alpha. + const first = scheme[0]; + const isAlpha = (c: string) => { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + }; + + if (!isAlpha(first)) { + return false; + } + + // Remaining characters must be ASCII alphanumeric, "+", "-", or ".". + const isSchemeChar = (c: string) => { + return isAlpha(c) || (c >= '0' && c <= '9') || c === '+' || c === '-' || c === '.'; + }; + return scheme.slice(1).split('').every(isSchemeChar); +} + interface QueryParam { name: string; value?: string; @@ -97,6 +139,7 @@ const stripOrigin = (url: string): string => { }; export { + hasExplicitScheme, encodeUrl, parseQueryParams, buildQueryString, diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 1445a0979..73fbf34d1 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -10,7 +10,7 @@ const { ipcMain } = require('electron'); const { each, get, extend, cloneDeep, merge } = require('lodash'); const { NtlmClient } = require('axios-ntlm'); const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime, formatErrorWithContextV2 } = require('@usebruno/js'); -const { encodeUrl } = require('@usebruno/common').utils; +const { encodeUrl, hasExplicitScheme } = require('@usebruno/common').utils; const { extractPromptVariables } = require('@usebruno/common').utils; const { interpolateString } = require('./interpolate-string'); const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper'); @@ -108,9 +108,8 @@ const configureRequest = async ( collectionPath, globalEnvironmentVariables ) => { - const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const hasVariables = request.url.startsWith('{{'); - if (!hasVariables && !protocolRegex.test(request.url)) { + if (!hasVariables && !hasExplicitScheme(request.url)) { request.url = `http://${request.url}`; } diff --git a/packages/bruno-electron/tests/network/index.spec.js b/packages/bruno-electron/tests/network/index.spec.js index 5fa443132..6df3dc876 100644 --- a/packages/bruno-electron/tests/network/index.spec.js +++ b/packages/bruno-electron/tests/network/index.spec.js @@ -1,15 +1,80 @@ const { configureRequest } = require('../../src/ipc/network/index'); -describe('index: configureRequest', () => { - it('Should add \'http://\' to the URL if no protocol is specified', async () => { - const request = { method: 'GET', url: 'test-domain', body: {} }; +// Integration tests: full configureRequest (URL must survive cookie-jar parse) +describe('index: configureRequest — URL normalization', () => { + it('prepends http:// to localhost:port', async () => { + const request = { method: 'GET', url: 'localhost:8080', body: {} }; await configureRequest(null, {}, request, null, null, null, null); - expect(request.url).toEqual('http://test-domain'); + expect(request.url).toEqual('http://localhost:8080'); }); - it('Should NOT add \'http://\' to the URL if a protocol is specified', async () => { + it('prepends http:// to localhost', async () => { + const request = { method: 'GET', url: 'localhost', body: {} }; + await configureRequest(null, {}, request, null, null, null, null); + expect(request.url).toEqual('http://localhost'); + }); + + it('prepends http:// to 127.0.0.1:port', async () => { + const request = { method: 'GET', url: '127.0.0.1:3000', body: {} }; + await configureRequest(null, {}, request, null, null, null, null); + expect(request.url).toEqual('http://127.0.0.1:3000'); + }); + + it('prepends http:// to example.com/api/v1', async () => { + const request = { method: 'GET', url: 'example.com/api/v1', body: {} }; + await configureRequest(null, {}, request, null, null, null, null); + expect(request.url).toEqual('http://example.com/api/v1'); + }); + + it('does not prepend http:// to http://example.com', async () => { + const request = { method: 'GET', url: 'http://example.com', body: {} }; + await configureRequest(null, {}, request, null, null, null, null); + expect(request.url).toEqual('http://example.com'); + }); + + it('does not prepend http:// to https://example.com', async () => { + const request = { method: 'GET', url: 'https://example.com', body: {} }; + await configureRequest(null, {}, request, null, null, null, null); + expect(request.url).toEqual('https://example.com'); + }); + + it('does not prepend http:// to ftp://test-domain', async () => { const request = { method: 'GET', url: 'ftp://test-domain', body: {} }; await configureRequest(null, {}, request, null, null, null, null); expect(request.url).toEqual('ftp://test-domain'); }); + + it('does not prepend http:// to ws://example.com/socket', async () => { + const request = { method: 'GET', url: 'ws://example.com/socket', body: {} }; + await configureRequest(null, {}, request, null, null, null, null); + expect(request.url).toEqual('ws://example.com/socket'); + }); + + describe('with variables in the url and no interpolation values', () => { + it('does not prepend http:// to {{baseUrl}}/api/v1 (template variable)', async () => { + const url = '{{baseUrl}}/api/v1'; + const request = { method: 'GET', url, body: {} }; + expect.assertions(2); + try { + await configureRequest(null, {}, request, null, null, null, null); + } catch (err) { + expect(err.message).toBe('Invalid URL'); + } finally { + expect(request.url).toEqual(url); + } + }); + + it('does not prepend http:// to {{baseUrl}} alone (template variable)', async () => { + const url = '{{baseUrl}}'; + const request = { method: 'GET', url, body: {} }; + expect.assertions(2); + try { + await configureRequest(null, {}, request, null, null, null, null); + } catch (err) { + expect(err.message).toBe('Invalid URL'); + } finally { + expect(request.url).toEqual(url); + } + }); + }); }); diff --git a/packages/bruno-tests/collection/url-serialization/scheme.bru b/packages/bruno-tests/collection/url-serialization/scheme.bru new file mode 100644 index 000000000..6c3618d3b --- /dev/null +++ b/packages/bruno-tests/collection/url-serialization/scheme.bru @@ -0,0 +1,16 @@ +meta { + name: scheme + type: http + seq: 1 +} + +get { + url: localhost:8081/ping + body: none + auth: none +} + +assert { + res.status: eq 200 + res.body: eq pong +}