Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Online config URL parsing #21

Merged
merged 9 commits into from
Jun 26, 2021
7 changes: 7 additions & 0 deletions build/shadowsocks_config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,10 @@ export declare const SIP002_URI: {
parse: (uri: string) => Config;
stringify: (config: Config) => string;
};
export interface ConfigFetchParams {
readonly location: string;
readonly certFingerprint?: string;
readonly httpMethod?: string;
}
export declare const ONLINE_CONFIG_PROTOCOL = 'ssconf';
export declare function parseOnlineConfigUrl(url: string): ConfigFetchParams;
46 changes: 41 additions & 5 deletions build/shadowsocks_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ var __extends = (this && this.__extends) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
var js_base64_1 = require("js-base64");
var ipaddr = require("ipaddr.js");
var js_base64_1 = require('js-base64');
var punycode = require("punycode");
var url_1 = require('url');
// Custom error base class
var ShadowsocksConfigError = /** @class */ (function (_super) {
__extends(ShadowsocksConfigError, _super);
Expand Down Expand Up @@ -81,8 +82,8 @@ var Host = /** @class */ (function (_super) {
}
if (ipaddr.isValid(host)) {
var ip = ipaddr.parse(host);
_this.isIPv4 = ip.kind() == "ipv4";
_this.isIPv6 = ip.kind() == "ipv6";
_this.isIPv4 = ip.kind() === 'ipv4';
_this.isIPv6 = ip.kind() === 'ipv6';
// Previous versions of outline-ShadowsocksConfig only accept
// IPv6 in normalized (expanded) form, so we normalize the
// input here to ensure that access keys remain compatible.
Expand Down Expand Up @@ -298,8 +299,8 @@ exports.LEGACY_BASE64_URI = {
var data = method.data + ":" + password.data + "@" + host.data + ":" + port.data;
var b64EncodedData = js_base64_1.Base64.encode(data);
// Remove "=" padding
while (b64EncodedData.slice(-1) == "=") {
b64EncodedData = b64EncodedData.slice(0, -1);
while (b64EncodedData.slice(-1) === '=') {
b64EncodedData = b64EncodedData.slice(0, -1);
}
return "ss://" + b64EncodedData + hash;
},
Expand Down Expand Up @@ -363,3 +364,38 @@ exports.SIP002_URI = {
return "ss://" + userInfo + "@" + uriHost + ":" + port.data + "/" + queryString + hash;
},
};
exports.ONLINE_CONFIG_PROTOCOL = 'ssconf';
// Parses access parameters to retrieve a Shadowsocks proxy config from an
// online config URL. See: https://github.com/shadowsocks/shadowsocks-org/issues/89
function parseOnlineConfigUrl(url) {
if (!url || !url.startsWith(exports.ONLINE_CONFIG_PROTOCOL + ':')) {
throw new InvalidUri('URI protocol must be "' + exports.ONLINE_CONFIG_PROTOCOL + '"');
}
// Replace the protocol "ssconf" with "https" to ensure correct results,
// otherwise some Safari versions fail to parse it.
var inputForUrlParser = url.replace(new RegExp('^' + exports.ONLINE_CONFIG_PROTOCOL), 'https');
// The built-in URL parser throws as desired when given URIs with invalid syntax.
var urlParserResult = new URL(inputForUrlParser);
// Use ValidatedConfigFields subclasses (Host, Port, Tag) to throw on validation failure.
var uriFormattedHost = urlParserResult.hostname;
var host;
try {
host = new Host(uriFormattedHost);
} catch (_) {
// Could be IPv6 host formatted with surrounding brackets, so try stripping first and last
// characters. If this throws, give up and let the exception propagate.
host = new Host(uriFormattedHost.substring(1, uriFormattedHost.length - 1));
}
// The default URL parser fails to recognize the default HTTPs port (443).
var port = new Port(urlParserResult.port || '443');
// Parse extra parameters from the tag, which has the URL search parameters format.
var tag = new Tag(urlParserResult.hash.substring(1));
var params = new url_1.URLSearchParams(tag.data);
return {
// Build the access URL with the parsed parameters Exclude the query string and tag.
location: 'https://' + uriFormattedHost + ':' + port.data + urlParserResult.pathname,
certFingerprint: params.get('certFp') || undefined,
httpMethod: params.get('httpMethod') || undefined
};
}
exports.parseOnlineConfigUrl = parseOnlineConfigUrl;
89 changes: 71 additions & 18 deletions src/shadowsocks_config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {
Host, Port, Method, Password, Tag, Config, makeConfig,
SHADOWSOCKS_URI, SIP002_URI, LEGACY_BASE64_URI, InvalidConfigField, InvalidUri,
} from './shadowsocks_config';
import {Base64} from 'js-base64';
import {Host, InvalidConfigField, InvalidUri, LEGACY_BASE64_URI, makeConfig, Method, parseOnlineConfigUrl, Password, Port, SHADOWSOCKS_URI, SIP002_URI, Tag,} from './shadowsocks_config';

describe('shadowsocks_config', () => {
describe('Config API', () => {
Expand Down Expand Up @@ -62,13 +58,12 @@ describe('shadowsocks_config', () => {
it('normalizes IPv6 address hosts', () => {
const testCases = [
// Canonical form
['::1', '0:0:0:0:0:0:0:1'],
['2001:db8::', '2001:db8:0:0:0:0:0:0'],
['::1', '0:0:0:0:0:0:0:1'], ['2001:db8::', '2001:db8:0:0:0:0:0:0'],
// Expanded form
['1:02:003:0004:005:06:7:08', '1:2:3:4:5:6:7:8'],
// IPv4-mapped form
['::ffff:192.0.2.128', '0:0:0:0:0:ffff:c000:280']
]
];
for (const [input, expanded] of testCases) {
const host = new Host(input);
expect(host.data).toEqual(expanded);
Expand Down Expand Up @@ -204,7 +199,6 @@ describe('shadowsocks_config', () => {
});

describe('URI serializer', () => {

it('can serialize a SIP002 URI', () => {
const config = makeConfig({
host: '192.168.100.1',
Expand All @@ -213,8 +207,8 @@ describe('shadowsocks_config', () => {
password: 'test',
tag: 'Foo Bar',
});
expect(SIP002_URI.stringify(config)).toEqual(
'ss://[email protected]:8888/#Foo%20Bar');
expect(SIP002_URI.stringify(config))
.toEqual('ss://[email protected]:8888/#Foo%20Bar');
});

it('can serialize a SIP002 URI with a non-latin password', () => {
Expand All @@ -225,8 +219,9 @@ describe('shadowsocks_config', () => {
password: '小洞不补大洞吃苦',
tag: 'Foo Bar',
});
expect(SIP002_URI.stringify(config)).toEqual(
'ss://[email protected]:8888/#Foo%20Bar');
expect(SIP002_URI.stringify(config))
.toEqual(
'ss://[email protected]:8888/#Foo%20Bar');
});

it('can serialize a SIP002 URI with IPv6 host', () => {
Expand All @@ -237,8 +232,9 @@ describe('shadowsocks_config', () => {
password: 'test',
tag: 'Foo Bar',
});
expect(SIP002_URI.stringify(config)).toEqual(
'ss://YWVzLTEyOC1nY206dGVzdA@[2001:0:ce49:7601:e866:efff:62c3:fffe]:8888/#Foo%20Bar');
expect(SIP002_URI.stringify(config))
.toEqual(
'ss://YWVzLTEyOC1nY206dGVzdA@[2001:0:ce49:7601:e866:efff:62c3:fffe]:8888/#Foo%20Bar');
});

it('can serialize a legacy base64 URI', () => {
Expand All @@ -261,8 +257,9 @@ describe('shadowsocks_config', () => {
password: '小洞不补大洞吃苦',
tag: 'Foo Bar',
});
expect(LEGACY_BASE64_URI.stringify(config)).toEqual(
'ss://YmYtY2ZiOuWwj+a0nuS4jeihpeWkp+a0nuWQg+iLpkAxOTIuMTY4LjEwMC4xOjg4ODg#Foo%20Bar');
expect(LEGACY_BASE64_URI.stringify(config))
.toEqual(
'ss://YmYtY2ZiOuWwj+a0nuS4jeihpeWkp+a0nuWQg+iLpkAxOTIuMTY4LjEwMC4xOjg4ODg#Foo%20Bar');
});
});

Expand Down Expand Up @@ -390,7 +387,8 @@ describe('shadowsocks_config', () => {
});

it('can parse a valid legacy base64 URI with a non-latin password', () => {
const input = 'ss://YmYtY2ZiOuWwj+a0nuS4jeihpeWkp+a0nuWQg+iLpkAxOTIuMTY4LjEwMC4xOjg4ODg#Foo%20Bar';
const input =
'ss://YmYtY2ZiOuWwj+a0nuS4jeihpeWkp+a0nuWQg+iLpkAxOTIuMTY4LjEwMC4xOjg4ODg#Foo%20Bar';
const config = SHADOWSOCKS_URI.parse(input);
expect(config.method.data).toEqual('bf-cfb');
expect(config.password.data).toEqual('小洞不补大洞吃苦');
Expand All @@ -408,4 +406,59 @@ describe('shadowsocks_config', () => {
expect(() => SHADOWSOCKS_URI.parse('ss://not-base64')).toThrowError(InvalidUri);
});
});

describe('SIP008', () => {
it('can parse a valid ssconf URI with domain name and extras', () => {
const input = encodeURI(
'ssconf://my.domain.com/secret/long/path#certFp=AA:BB:CC:DD:EE:FF&httpMethod=POST');
const onlineConfig = parseOnlineConfigUrl(input);
expect(new URL(onlineConfig.location))
.toEqual(new URL('https://my.domain.com/secret/long/path'));
expect(onlineConfig.certFingerprint).toEqual('AA:BB:CC:DD:EE:FF');
expect(onlineConfig.httpMethod).toEqual('POST');
});

it('can parse a valid ssconf URI with domain name and custom port', () => {
const input =
encodeURI('ssconf://my.domain.com:9090/secret/long/path#certFp=AA:BB:CC:DD:EE:FF');
const onlineConfig = parseOnlineConfigUrl(input);
expect(new URL(onlineConfig.location))
.toEqual(new URL('https://my.domain.com:9090/secret/long/path'));
expect(onlineConfig.certFingerprint).toEqual('AA:BB:CC:DD:EE:FF');
});

it('can parse a valid ssconf URI with hostname and no path', () => {
const input = encodeURI('ssconf://my.domain.com');
const onlineConfig = parseOnlineConfigUrl(input);
expect(new URL(onlineConfig.location)).toEqual(new URL('https://my.domain.com'));
expect(onlineConfig.certFingerprint).toBeUndefined();
});

it('can parse a valid ssconf URI with IPv4 address', () => {
const input =
encodeURI('ssconf://1.2.3.4/secret/long/path#certFp=AA:BB:CC:DD:EE:FF&other=param');
const onlineConfig = parseOnlineConfigUrl(input);
expect(new URL(onlineConfig.location)).toEqual(new URL('https://1.2.3.4/secret/long/path'));
expect(onlineConfig.certFingerprint).toEqual('AA:BB:CC:DD:EE:FF');
});

it('can parse a valid ssconf URI with IPv6 address and custom port', () => {
// encodeURI encodes the IPv6 address brackets.
const input = `ssconf://[2001:0:ce49:7601:e866:efff:62c3:fffe]:8081/secret/long/path#certFp=${
encodeURIComponent('AA:BB:CC:DD:EE:FF')}`;
const onlineConfig = parseOnlineConfigUrl(input);
expect(new URL(onlineConfig.location))
.toEqual(new URL('https://[2001:0:ce49:7601:e866:efff:62c3:fffe]:8081/secret/long/path'));
expect(onlineConfig.certFingerprint).toEqual('AA:BB:CC:DD:EE:FF');
});

it('can parse a valid ssconf URI with URI-encoded tag', () => {
const certFp = '&=?:%';
const input = `ssconf://1.2.3.4/secret#certFp=${encodeURIComponent(certFp)}&httpMethod=GET`;
const onlineConfig = parseOnlineConfigUrl(input);
expect(new URL(onlineConfig.location)).toEqual(new URL('https://1.2.3.4/secret'));
expect(onlineConfig.certFingerprint).toEqual(certFp);
expect(onlineConfig.httpMethod).toEqual('GET');
});
});
});
60 changes: 53 additions & 7 deletions src/shadowsocks_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import * as ipaddr from 'ipaddr.js';
import {Base64} from 'js-base64';
import * as ipaddr from 'ipaddr.js'
import * as punycode from 'punycode';
import {URLSearchParams} from 'url';

// Custom error base class
export class ShadowsocksConfigError extends Error {
Expand Down Expand Up @@ -53,13 +54,13 @@ export class Host extends ValidatedConfigField {
host = host.data;
}
if (ipaddr.isValid(host)) {
const ip = ipaddr.parse(host)
this.isIPv4 = ip.kind() == "ipv4"
this.isIPv6 = ip.kind() == "ipv6"
const ip = ipaddr.parse(host);
this.isIPv4 = ip.kind() === 'ipv4';
this.isIPv6 = ip.kind() === 'ipv6';
// Previous versions of outline-ShadowsocksConfig only accept
// IPv6 in normalized (expanded) form, so we normalize the
// input here to ensure that access keys remain compatible.
host = ip.toNormalizedString()
host = ip.toNormalizedString();
} else {
host = punycode.toASCII(host) as string;
this.isHostname = Host.HOSTNAME_PATTERN.test(host);
Expand Down Expand Up @@ -274,8 +275,8 @@ export const LEGACY_BASE64_URI = {
const data = `${method.data}:${password.data}@${host.data}:${port.data}`;
let b64EncodedData = Base64.encode(data);
// Remove "=" padding
while (b64EncodedData.slice(-1) == "=") {
b64EncodedData = b64EncodedData.slice(0, -1)
while (b64EncodedData.slice(-1) === '=') {
b64EncodedData = b64EncodedData.slice(0, -1);
}
return `ss://${b64EncodedData}${hash}`;
},
Expand Down Expand Up @@ -338,3 +339,48 @@ export const SIP002_URI = {
return `ss://${userInfo}@${uriHost}:${port.data}/${queryString}${hash}`;
},
};

export interface ConfigFetchParams {
// URL endpoint to retrieve a Shadowsocks configuration.
readonly location: string;
// Server cerficate hash.
readonly certFingerprint?: string;
// HTTP method to use when accessing `url`.
readonly httpMethod?: string;
}

export const ONLINE_CONFIG_PROTOCOL = 'ssconf';

// Parses access parameters to retrieve a Shadowsocks proxy config from an
// online config URL. See: https://github.com/shadowsocks/shadowsocks-org/issues/89
export function parseOnlineConfigUrl(url: string): ConfigFetchParams {
if (!url || !url.startsWith(`${ONLINE_CONFIG_PROTOCOL}:`)) {
throw new InvalidUri(`URI protocol must be "${ONLINE_CONFIG_PROTOCOL}"`);
}
// Replace the protocol "ssconf" with "https" to ensure correct results,
// otherwise some Safari versions fail to parse it.
const inputForUrlParser = url.replace(new RegExp(`^${ONLINE_CONFIG_PROTOCOL}`), 'https');
// The built-in URL parser throws as desired when given URIs with invalid syntax.
const urlParserResult = new URL(inputForUrlParser);
// Use ValidatedConfigFields subclasses (Host, Port, Tag) to throw on validation failure.
const uriFormattedHost = urlParserResult.hostname;
let host: Host;
try {
host = new Host(uriFormattedHost);
} catch (_) {
// Could be IPv6 host formatted with surrounding brackets, so try stripping first and last
// characters. If this throws, give up and let the exception propagate.
host = new Host(uriFormattedHost.substring(1, uriFormattedHost.length - 1));
}
// The default URL parser fails to recognize the default HTTPs port (443).
const port = new Port(urlParserResult.port || '443');
// Parse extra parameters from the tag, which has the URL search parameters format.
const tag = new Tag(urlParserResult.hash.substring(1));
const params = new URLSearchParams(tag.data);
return {
// Build the access URL with the parsed parameters Exclude the query string and tag.
location: `https://${uriFormattedHost}:${port.data}${urlParserResult.pathname}`,
certFingerprint: params.get('certFp') || undefined,
httpMethod: params.get('httpMethod') || undefined
};
}