feat: add default http protocol when URL scheme is missing (#7786)

---------

Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
Kanhaiya Pandey
2026-05-20 20:43:47 +05:30
committed by GitHub
parent 4b214693c4
commit c5528a75a6
8 changed files with 193 additions and 20 deletions

View File

@@ -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}`;
}

View File

@@ -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');

View File

@@ -1,4 +1,5 @@
export {
hasExplicitScheme,
encodeUrl,
parseQueryParams,
buildQueryString,

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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}`;
}

View File

@@ -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);
}
});
});
});

View File

@@ -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
}