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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getLogger,
handleError,
isValidGraphName,
isValidGrpcNamingScheme,
isValidLabels,
} from '../../util.js';
import { UnauthorizedError } from '../../errors/errors.js';
Expand Down Expand Up @@ -166,6 +167,19 @@ export function createFederatedSubgraph(
admissionErrors: [],
};
}
// For GRPC_SERVICE subgraphs, validate that routing URL follows gRPC naming scheme
if (req.type === SubgraphType.GRPC_SERVICE && !isValidGrpcNamingScheme(routingUrl)) {
return {
response: {
code: EnumStatusCode.ERR,
details:
`Routing URL must follow gRPC naming scheme. ` +
`See https://grpc.io/docs/guides/custom-name-resolution/ for examples.`,
},
compositionErrors: [],
admissionErrors: [],
};
}
if (req.subscriptionUrl && !isValidUrl(req.subscriptionUrl)) {
return {
response: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getLogger,
handleError,
isValidGraphName,
isValidGrpcNamingScheme,
isValidLabels,
isValidPluginVersion,
newCompositionOptions,
Expand Down Expand Up @@ -388,6 +389,21 @@ export function publishFederatedSubgraph(
proposalMatchMessage,
};
}
// For GRPC_SERVICE subgraphs, validate that routing URL follows gRPC naming scheme
if (req.type === SubgraphType.GRPC_SERVICE && !isValidGrpcNamingScheme(routingUrl)) {
return {
response: {
code: EnumStatusCode.ERR,
details:
`Routing URL must follow gRPC naming scheme. ` +
`See https://grpc.io/docs/guides/custom-name-resolution/ for examples.`,
},
compositionErrors: [],
deploymentErrors: [],
compositionWarnings: [],
proposalMatchMessage,
};
}

if (req.subscriptionUrl && !isValidUrl(req.subscriptionUrl)) {
return {
Expand Down
26 changes: 25 additions & 1 deletion controlplane/src/core/bufservices/subgraph/updateSubgraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import { PlainMessage } from '@bufbuild/protobuf';
import { HandlerContext } from '@connectrpc/connect';
import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb';
import { OrganizationEventName } from '@wundergraph/cosmo-connect/dist/notifications/events_pb';
import { UpdateSubgraphRequest, UpdateSubgraphResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb';
import {
SubgraphType,
UpdateSubgraphRequest,
UpdateSubgraphResponse,
} from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb';
import { isValidUrl } from '@wundergraph/cosmo-shared';
import { AuditLogRepository } from '../../repositories/AuditLogRepository.js';
import { DefaultNamespace } from '../../repositories/NamespaceRepository.js';
import { SubgraphRepository } from '../../repositories/SubgraphRepository.js';
import type { RouterOptions } from '../../routes.js';
import {
enrichLogger,
formatSubgraphType,
formatSubscriptionProtocol,
formatWebsocketSubprotocol,
getLogger,
handleError,
isValidGrpcNamingScheme,
isValidLabels,
newCompositionOptions,
} from '../../util.js';
Expand Down Expand Up @@ -143,6 +149,24 @@ export function updateSubgraph(
compositionWarnings: [],
};
}
// For GRPC_SERVICE subgraphs, validate that routing URL follows gRPC naming scheme
if (
req.routingUrl !== undefined &&
subgraph.type === formatSubgraphType(SubgraphType.GRPC_SERVICE) &&
!isValidGrpcNamingScheme(req.routingUrl)
) {
return {
response: {
code: EnumStatusCode.ERR,
details:
`Routing URL must follow gRPC naming scheme. ` +
`See https://grpc.io/docs/guides/custom-name-resolution/ for examples.`,
},
compositionErrors: [],
deploymentErrors: [],
compositionWarnings: [],
};
}
// When un-setting the url, the url can be an empty string
if (req.subscriptionUrl && !isValidUrl(req.subscriptionUrl)) {
return {
Expand Down
218 changes: 217 additions & 1 deletion controlplane/src/core/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, test } from 'vitest';
import { extractOperationNames, hasLabelsChanged, isValidLabels, isValidNamespaceName } from './util.js';
import {
extractOperationNames,
hasLabelsChanged,
isValidGrpcNamingScheme,
isValidLabels,
isValidNamespaceName,
} from './util.js';
import { organizationSlugSchema } from './constants.js';

describe('Util', (ctx) => {
Expand Down Expand Up @@ -276,3 +282,213 @@ describe('extract operation names', () => {
expect(operationNames).toEqual(['getTaskAndUser', 'completedTasks']);
});
});

describe('isValidGrpcNamingScheme', () => {
describe('DNS scheme (default and explicit)', () => {
test('should accept plain hostname (defaults to DNS)', () => {
expect(isValidGrpcNamingScheme('localhost')).toBe(true);
expect(isValidGrpcNamingScheme('example.com')).toBe(true);
expect(isValidGrpcNamingScheme('subdomain.example.com')).toBe(true);
});

test('should accept hostname with port (defaults to DNS)', () => {
expect(isValidGrpcNamingScheme('localhost:8080')).toBe(true);
expect(isValidGrpcNamingScheme('example.com:443')).toBe(true);
expect(isValidGrpcNamingScheme('subdomain.example.com:9090')).toBe(true);
});

test('should accept explicit DNS scheme', () => {
expect(isValidGrpcNamingScheme('dns:localhost')).toBe(true);
expect(isValidGrpcNamingScheme('dns:localhost:8080')).toBe(true);
expect(isValidGrpcNamingScheme('dns:example.com:443')).toBe(true);
expect(isValidGrpcNamingScheme('dns:///example.com:8080')).toBe(true);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('should accept DNS with authority', () => {
expect(isValidGrpcNamingScheme('dns://8.8.8.8/example.com:8080')).toBe(true);
});

test('should reject DNS with authority but no host:port path', () => {
expect(isValidGrpcNamingScheme('dns://8.8.8.8')).toBe(false);
expect(isValidGrpcNamingScheme('dns://example.com')).toBe(false);
});

test('should reject DNS with authority but empty endpoint', () => {
expect(isValidGrpcNamingScheme('dns://8.8.8.8/')).toBe(false);
expect(isValidGrpcNamingScheme('dns://example.com/')).toBe(false);
});

test('should accept DNS with three slashes and valid host:port', () => {
expect(isValidGrpcNamingScheme('dns:///example.com:8080')).toBe(true);
expect(isValidGrpcNamingScheme('dns:///localhost:443')).toBe(true);
});

test('should reject DNS with three slashes but empty endpoint', () => {
expect(isValidGrpcNamingScheme('dns:///')).toBe(false);
});

test('should reject DNS with invalid ports', () => {
expect(isValidGrpcNamingScheme('localhost:0')).toBe(false);
expect(isValidGrpcNamingScheme('example.com:65536')).toBe(false);
expect(isValidGrpcNamingScheme('subdomain.example.com:99999')).toBe(false);
expect(isValidGrpcNamingScheme('dns:localhost:0')).toBe(false);
expect(isValidGrpcNamingScheme('dns:example.com:65536')).toBe(false);
expect(isValidGrpcNamingScheme('dns://example.com:99999')).toBe(false);
});
});

describe('Unix domain sockets', () => {
test('should accept unix:path format', () => {
expect(isValidGrpcNamingScheme('unix:/tmp/socket')).toBe(true);
expect(isValidGrpcNamingScheme('unix:./relative/path')).toBe(true);
expect(isValidGrpcNamingScheme('unix:socket')).toBe(true);
});

test('should accept unix:///absolute_path format', () => {
expect(isValidGrpcNamingScheme('unix:///tmp/socket')).toBe(true);
expect(isValidGrpcNamingScheme('unix:///var/run/socket')).toBe(true);
});

test('should reject invalid unix paths', () => {
expect(isValidGrpcNamingScheme('unix:')).toBe(false);
expect(isValidGrpcNamingScheme('unix:///')).toBe(false);
});
});

describe('Unix abstract sockets', () => {
test('should accept unix-abstract:abstract_path', () => {
expect(isValidGrpcNamingScheme('unix-abstract:socket')).toBe(true);
expect(isValidGrpcNamingScheme('unix-abstract:my-socket-name')).toBe(true);
});

test('should reject invalid unix-abstract paths', () => {
expect(isValidGrpcNamingScheme('unix-abstract:')).toBe(false);
});
});

describe('VSOCK', () => {
test('should accept valid vsock:cid:port', () => {
expect(isValidGrpcNamingScheme('vsock:1:8080')).toBe(true);
expect(isValidGrpcNamingScheme('vsock:0:443')).toBe(true);
expect(isValidGrpcNamingScheme('vsock:4294967295:65535')).toBe(true);
});

test('should reject invalid vsock formats', () => {
expect(isValidGrpcNamingScheme('vsock:1')).toBe(false);
expect(isValidGrpcNamingScheme('vsock:1:8080:extra')).toBe(false);
expect(isValidGrpcNamingScheme('vsock:abc:8080')).toBe(false);
expect(isValidGrpcNamingScheme('vsock:1:abc')).toBe(false);
expect(isValidGrpcNamingScheme('vsock:-1:8080')).toBe(false);
expect(isValidGrpcNamingScheme('vsock:1:-1')).toBe(false);
});

test('should reject vsock with invalid port ranges', () => {
expect(isValidGrpcNamingScheme('vsock:1:0')).toBe(false);
expect(isValidGrpcNamingScheme('vsock:1:65536')).toBe(false);
expect(isValidGrpcNamingScheme('vsock:0:99999')).toBe(false);
});
});

describe('IPv4 addresses', () => {
test('should accept valid ipv4:address:port', () => {
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1:8080')).toBe(true);
expect(isValidGrpcNamingScheme('ipv4:192.168.1.1:443')).toBe(true);
expect(isValidGrpcNamingScheme('ipv4:0.0.0.0:80')).toBe(true);
expect(isValidGrpcNamingScheme('ipv4:255.255.255.255:65535')).toBe(true);
});

test('should accept ipv4:address without port', () => {
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1')).toBe(true);
expect(isValidGrpcNamingScheme('ipv4:192.168.1.1')).toBe(true);
});

test('should accept multiple IPv4 addresses', () => {
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1:8080,192.168.1.1:9090')).toBe(true);
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1,192.168.1.1:9090')).toBe(true);
});

test('should reject invalid IPv4 addresses', () => {
expect(isValidGrpcNamingScheme('ipv4:256.0.0.1:8080')).toBe(false);
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1.1:8080')).toBe(false);
expect(isValidGrpcNamingScheme('ipv4:localhost:8080')).toBe(false);
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1:-1')).toBe(false);
});

test('should reject IPv4 with invalid port ranges', () => {
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1:0')).toBe(false);
expect(isValidGrpcNamingScheme('ipv4:192.168.1.1:65536')).toBe(false);
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1:99999')).toBe(false);
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1:8080,192.168.1.1:0')).toBe(false);
expect(isValidGrpcNamingScheme('ipv4:127.0.0.1:8080,192.168.1.1:65536')).toBe(false);
});
});

describe('IPv6 addresses', () => {
test('should accept valid ipv6:address:port with brackets', () => {
expect(isValidGrpcNamingScheme('ipv6:[::1]:8080')).toBe(true);
expect(isValidGrpcNamingScheme('ipv6:[2001:db8::1]:443')).toBe(true);
expect(isValidGrpcNamingScheme('ipv6:[::]:1234')).toBe(true);
});

test('should accept valid ipv6:address without port', () => {
expect(isValidGrpcNamingScheme('ipv6:[::1]')).toBe(true);
expect(isValidGrpcNamingScheme('ipv6:[2001:db8::1]')).toBe(true);
expect(isValidGrpcNamingScheme('ipv6:::1')).toBe(true);
expect(isValidGrpcNamingScheme('ipv6:2001:db8::1')).toBe(true);
});

test('should accept multiple IPv6 addresses', () => {
expect(isValidGrpcNamingScheme('ipv6:[::1]:8080,[2001:db8::1]:9090')).toBe(true);
});

test('should reject invalid IPv6 addresses', () => {
expect(isValidGrpcNamingScheme('ipv6:[::1]:-1')).toBe(false);
expect(isValidGrpcNamingScheme('ipv6:invalid')).toBe(false);
});

test('should reject IPv6 with invalid port ranges', () => {
expect(isValidGrpcNamingScheme('ipv6:[::1]:0')).toBe(false);
expect(isValidGrpcNamingScheme('ipv6:[::1]:65536')).toBe(false);
expect(isValidGrpcNamingScheme('ipv6:[2001:db8::1]:99999')).toBe(false);
expect(isValidGrpcNamingScheme('ipv6:[::1]:8080,[2001:db8::1]:0')).toBe(false);
expect(isValidGrpcNamingScheme('ipv6:[::1]:8080,[2001:db8::1]:65536')).toBe(false);
});
});

describe('Invalid URLs (HTTP/HTTPS)', () => {
test('should reject HTTP URLs', () => {
expect(isValidGrpcNamingScheme('http://localhost:8080')).toBe(false);
expect(isValidGrpcNamingScheme('http://example.com')).toBe(false);
expect(isValidGrpcNamingScheme('http://example.com:8080')).toBe(false);
});

test('should reject HTTPS URLs', () => {
expect(isValidGrpcNamingScheme('https://localhost:8080')).toBe(false);
expect(isValidGrpcNamingScheme('https://example.com')).toBe(false);
expect(isValidGrpcNamingScheme('https://example.com:443')).toBe(false);
});
});

describe('Edge cases', () => {
test('should reject empty strings', () => {
expect(isValidGrpcNamingScheme('')).toBe(false);
expect(isValidGrpcNamingScheme(' ')).toBe(false);
});

test('should handle whitespace', () => {
expect(isValidGrpcNamingScheme(' localhost:8080 ')).toBe(true);
expect(isValidGrpcNamingScheme(' dns:localhost:8080 ')).toBe(true);
});

test('should reject unknown schemes', () => {
expect(isValidGrpcNamingScheme('ftp://example.com')).toBe(false);
expect(isValidGrpcNamingScheme('ws://example.com')).toBe(false);
expect(isValidGrpcNamingScheme('file:///path')).toBe(false);
});

test('should reject malformed URLs', () => {
expect(isValidGrpcNamingScheme('://invalid')).toBe(false);
expect(isValidGrpcNamingScheme('invalid:')).toBe(false);
});
});
});
Loading
Loading