Skip to content

Commit bd552f9

Browse files
committed
feat(middleware-bucket-endpoint): implement hostname population from ARN
1 parent 6281d39 commit bd552f9

File tree

6 files changed

+135
-46
lines changed

6 files changed

+135
-46
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
describe("parseAccessPointArn", () => {
2+
it("test", () => {
3+
expect(1).toBe(1);
4+
});
5+
});

packages/middleware-bucket-endpoint/src/bucketArnUtils.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { ARN } from "@aws-sdk/util-arn-parser";
22

3+
import { ArnHostnameParameters } from "./bucketHostname";
4+
35
export interface AccessPointArn extends ARN {
46
accessPointName: string;
57
}
68

79
interface RequestOptions {
810
clientRegion: string;
9-
requestSuffix: string;
11+
clientPartition: string;
1012
useArnRegion: boolean;
1113
}
1214

@@ -16,16 +18,19 @@ export const parseAccessPointArn = (arn: ARN, requestOptions: RequestOptions): A
1618
throw new Error("expect 's3' in access point ARN service component");
1719
}
1820
validateRegion(region, { ...requestOptions });
21+
validatePartition(partition, { ...requestOptions });
1922
validateAccountId(accountId);
2023
const [, accessPointName] = parseAccessPointResource(resource);
24+
validateDNSHostLabel(`${accessPointName}-${accountId}`);
2125
return {
2226
...arn,
2327
accessPointName,
2428
};
2529
};
2630

27-
const validatePartition = (partition: string, options: { requestSuffix: string }) => {
28-
if (options.requestSuffix) {
31+
const validatePartition = (partition: string, options: { clientPartition: string }) => {
32+
if (partition !== options.clientPartition) {
33+
throw new Error(`Partition in ARN is incompatible, got ${partition} but expected ${options.clientPartition}`);
2934
}
3035
};
3136

@@ -39,18 +44,39 @@ const validateRegion = (
3944
if (region === "") {
4045
throw new Error("Access point ARN region is empty");
4146
}
42-
if (isFipsPseudoRegion(options.clientRegion) && !options.useArnRegion) {
47+
if (!options.useArnRegion && !isEqualRegions(region, options.clientRegion)) {
48+
throw new Error(`Region in ARN is incompatible, got ${region} but expected ${options.clientRegion}`);
49+
}
50+
if (options.useArnRegion && isFipsRegion(region)) {
51+
throw new Error("Region in ARN is a FIPS region");
4352
}
4453
};
4554

46-
const isFipsPseudoRegion = (region: string) => region.startsWith("fips-") || region.endsWith("-fips");
55+
const isFipsRegion = (region: string) => region.startsWith("fips-") || region.endsWith("-fips");
56+
57+
const getPseudoRegion = (region: string) => region.replace(/fips-|-fips/, "");
58+
59+
const isEqualRegions = (regionA: string, regionB: string) =>
60+
regionA === regionB || getPseudoRegion(regionA) === regionB || regionA === getPseudoRegion(regionB);
4761

4862
const validateAccountId = (accountId: string) => {
4963
if (!/[0-9]{12}/.exec(accountId)) {
5064
throw new Error("Access point ARN accountID does not match regex '[0-9]{12}'");
5165
}
5266
};
5367

68+
const validateDNSHostLabel = (label: string) => {
69+
// reference: https://tools.ietf.org/html/rfc3986#section-3.2.2
70+
if (
71+
label.length >= 64 ||
72+
!/^[a-z0-9][a-z0-9.-]+[a-z0-9]$/.test(label) ||
73+
/(\d+\.){3}\d+/.test(label) ||
74+
/[.-]{2}/.test(label)
75+
) {
76+
throw new Error(`Invalid DNS label ${label}.`);
77+
}
78+
};
79+
5480
const parseAccessPointResource = (resource: string): [string, string] => {
5581
if (resource.indexOf("accesspoint:") !== 0 && resource.indexOf("accesspoint/") !== 0) {
5682
throw new Error("Access point ARN resource should begin with 'accesspoint/'");
@@ -62,4 +88,22 @@ const parseAccessPointResource = (resource: string): [string, string] => {
6288
return parsedResource as [string, string];
6389
};
6490

65-
//TODO: The SDK must validate that populated {accesspoint-name}-{account-id} endpoint prefix results in a RFC 3986 Host label.
91+
export const populateAccessPointEndpoint = (
92+
arn: AccessPointArn,
93+
options: ArnHostnameParameters & { requestSuffix: string }
94+
): string => {
95+
const { pathStyleEndpoint, dualstackEndpoint, accelerateEndpoint, tlsCompatible } = options;
96+
if (pathStyleEndpoint) {
97+
throw new Error("Path-style S3 endpoint is not supported when bucket is an Access Point ARN");
98+
}
99+
if (accelerateEndpoint) {
100+
throw new Error("Accelerate is not supported when bucket is an Access Point ARN");
101+
}
102+
if (!tlsCompatible) {
103+
throw new Error("Access Point can only be used with https");
104+
}
105+
const { accessPointName, accountId, region } = arn;
106+
return `${accessPointName}-${accountId}.s3-accesspoint${dualstackEndpoint ? ".dualstack" : ""}.${region}.${
107+
options.requestSuffix
108+
}`;
109+
};

packages/middleware-bucket-endpoint/src/bucketEndpointMiddleware.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,21 @@ describe("bucketEndpointMiddleware", () => {
1414
path: "/bucket",
1515
};
1616
const next = jest.fn();
17+
const previouslyResolvedConfig = {
18+
region: () => Promise.resolve("us-foo-1"),
19+
regionInfoProvider: () => Promise.resolve({ hostname: "foo.us-foo-1.amazonaws.com" }),
20+
};
1721

1822
beforeEach(() => {
1923
next.mockClear();
2024
});
2125

2226
it("should convert the request provided into one directed to a virtual hosted-style endpoint", async () => {
2327
const request = new HttpRequest(requestInput);
24-
const handler = bucketEndpointMiddleware(resolveBucketEndpointConfig({}))(next, {} as any);
28+
const handler = bucketEndpointMiddleware(resolveBucketEndpointConfig({ ...previouslyResolvedConfig }))(
29+
next,
30+
{} as any
31+
);
2532
await handler({ input, request });
2633

2734
const {
@@ -38,6 +45,7 @@ describe("bucketEndpointMiddleware", () => {
3845
const request = new HttpRequest(requestInput);
3946
const handler = bucketEndpointMiddleware(
4047
resolveBucketEndpointConfig({
48+
...previouslyResolvedConfig,
4149
forcePathStyle: true,
4250
})
4351
)(next, {} as any);
@@ -57,6 +65,7 @@ describe("bucketEndpointMiddleware", () => {
5765
const request = new HttpRequest(requestInput);
5866
const handler = bucketEndpointMiddleware(
5967
resolveBucketEndpointConfig({
68+
...previouslyResolvedConfig,
6069
bucketEndpoint: true,
6170
})
6271
)(next, {} as any);
@@ -77,6 +86,7 @@ describe("bucketEndpointMiddleware", () => {
7786
const request = new HttpRequest(requestInput);
7887
const handler = bucketEndpointMiddleware(
7988
resolveBucketEndpointConfig({
89+
...previouslyResolvedConfig,
8090
useAccelerateEndpoint: true,
8191
})
8292
)(next, {} as any);
@@ -96,6 +106,7 @@ describe("bucketEndpointMiddleware", () => {
96106
const request = new HttpRequest(requestInput);
97107
const handler = bucketEndpointMiddleware(
98108
resolveBucketEndpointConfig({
109+
...previouslyResolvedConfig,
99110
useDualstackEndpoint: true,
100111
})
101112
)(next, {} as any);
@@ -115,6 +126,7 @@ describe("bucketEndpointMiddleware", () => {
115126
const request = new HttpRequest(requestInput);
116127
const handler = bucketEndpointMiddleware(
117128
resolveBucketEndpointConfig({
129+
...previouslyResolvedConfig,
118130
useAccelerateEndpoint: true,
119131
useDualstackEndpoint: true,
120132
})

packages/middleware-bucket-endpoint/src/bucketEndpointMiddleware.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
} from "@aws-sdk/types";
1111
import { parse as parseArn, validate as validateArn } from "@aws-sdk/util-arn-parser";
1212

13-
// import { parseArn } from "./bucketArnUtils";
1413
import { bucketHostname } from "./bucketHostname";
1514
import { BucketEndpointResolvedConfig } from "./configurations";
1615

@@ -25,6 +24,7 @@ export function bucketEndpointMiddleware(options: BucketEndpointResolvedConfig):
2524
if (options.bucketEndpoint) {
2625
request.hostname = bucketName;
2726
} else {
27+
const clientRegion = await options.region();
2828
const { hostname, bucketEndpoint } = bucketHostname({
2929
bucketName: validateArn(bucketName) ? parseArn(bucketName) : bucketName,
3030
baseHostname: request.hostname,
@@ -33,6 +33,8 @@ export function bucketEndpointMiddleware(options: BucketEndpointResolvedConfig):
3333
pathStyleEndpoint: options.forcePathStyle,
3434
tlsCompatible: request.protocol === "https:",
3535
useArnRegion: await options.useArnRegion(),
36+
clientRegion,
37+
clientPartition: (await options.regionInfoProvider(clientRegion))?.partition,
3638
});
3739

3840
request.hostname = hostname;
Lines changed: 53 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { ARN } from "@aws-sdk/util-arn-parser";
1+
import { ARN } from "@aws-sdk/util-arn-parser/src";
22

3-
import { AccessPointArn } from "./bucketArnUtils";
3+
import { parseAccessPointArn } from "./bucketArnUtils";
4+
import { populateAccessPointEndpoint } from "./bucketArnUtils";
45

56
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9\.\-]{1,61}[a-z0-9]$/;
67
const IP_ADDRESS_PATTERN = /(\d+\.){3}\d+/;
@@ -11,28 +12,53 @@ const S3_US_EAST_1_ALTNAME_PATTERN = /^s3(-external-1)?\.amazonaws\.com$/;
1112
const AWS_PARTITION_SUFFIX = "amazonaws.com";
1213

1314
export interface BucketHostnameParameters {
14-
accelerateEndpoint?: boolean;
1515
baseHostname: string;
16-
bucketName: string | ARN;
16+
bucketName: string;
17+
accelerateEndpoint?: boolean;
1718
dualstackEndpoint?: boolean;
1819
pathStyleEndpoint?: boolean;
1920
tlsCompatible?: boolean;
20-
useArnRegion?: boolean;
2121
}
2222

23+
export interface ArnHostnameParameters extends Omit<BucketHostnameParameters, "bucketName"> {
24+
bucketName: ARN;
25+
clientRegion: string;
26+
useArnRegion: boolean;
27+
clientPartition?: string;
28+
}
29+
30+
const isBucketNameOptions = (
31+
options: BucketHostnameParameters | ArnHostnameParameters
32+
): options is BucketHostnameParameters => typeof options.bucketName === "string";
33+
2334
export interface BucketHostname {
2435
hostname: string;
2536
bucketEndpoint: boolean;
2637
}
2738

28-
export function bucketHostname({
39+
export const bucketHostname = (options: BucketHostnameParameters | ArnHostnameParameters): BucketHostname => {
40+
if (isBucketNameOptions(options)) {
41+
// Construct endpoint when bucketName is a string referring to a bucket name
42+
return getEndpointFromBucketName(options);
43+
} else {
44+
// Construct endpoint when bucketName is an ARN referring to an S3 resource like Access Point
45+
const accessPointArn = parseAccessPointArn(options.bucketName, { ...options, clientPartition: "aws" });
46+
const [, requestSuffix] = partitionSuffix(options.baseHostname);
47+
return {
48+
bucketEndpoint: true,
49+
hostname: populateAccessPointEndpoint(accessPointArn, { ...options, requestSuffix }),
50+
};
51+
}
52+
};
53+
54+
const getEndpointFromBucketName = ({
2955
accelerateEndpoint = false,
3056
baseHostname,
3157
bucketName,
3258
dualstackEndpoint = false,
3359
pathStyleEndpoint = false,
3460
tlsCompatible = true,
35-
}: BucketHostnameParameters): BucketHostname {
61+
}: BucketHostnameParameters): BucketHostname => {
3662
if (!S3_HOSTNAME_PATTERN.test(baseHostname)) {
3763
return {
3864
bucketEndpoint: false,
@@ -44,31 +70,24 @@ export function bucketHostname({
4470
? ["us-east-1", AWS_PARTITION_SUFFIX]
4571
: partitionSuffix(baseHostname);
4672

47-
if (typeof bucketName === "string") {
48-
if (
49-
pathStyleEndpoint ||
50-
!isDnsCompatibleBucketName(bucketName) ||
51-
(tlsCompatible && DOT_PATTERN.test(bucketName))
52-
) {
53-
return {
54-
bucketEndpoint: false,
55-
hostname: dualstackEndpoint ? `s3.dualstack.${region}.${hostnameSuffix}` : baseHostname,
56-
};
57-
}
58-
59-
if (accelerateEndpoint) {
60-
baseHostname = `s3-accelerate${dualstackEndpoint ? ".dualstack" : ""}.${hostnameSuffix}`;
61-
} else if (dualstackEndpoint) {
62-
baseHostname = `s3.dualstack.${region}.${hostnameSuffix}`;
63-
}
64-
73+
if (pathStyleEndpoint || !isDnsCompatibleBucketName(bucketName) || (tlsCompatible && DOT_PATTERN.test(bucketName))) {
6574
return {
66-
bucketEndpoint: true,
67-
hostname: `${bucketName}.${baseHostname}`,
75+
bucketEndpoint: false,
76+
hostname: dualstackEndpoint ? `s3.dualstack.${region}.${hostnameSuffix}` : baseHostname,
6877
};
69-
} else {
7078
}
71-
}
79+
80+
if (accelerateEndpoint) {
81+
baseHostname = `s3-accelerate${dualstackEndpoint ? ".dualstack" : ""}.${hostnameSuffix}`;
82+
} else if (dualstackEndpoint) {
83+
baseHostname = `s3.dualstack.${region}.${hostnameSuffix}`;
84+
}
85+
86+
return {
87+
bucketEndpoint: true,
88+
hostname: `${bucketName}.${baseHostname}`,
89+
};
90+
};
7291

7392
/**
7493
* Determines whether a given string is DNS compliant per the rules outlined by
@@ -77,12 +96,10 @@ export function bucketHostname({
7796
*
7897
* @see https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html
7998
*/
80-
function isDnsCompatibleBucketName(bucketName: string): boolean {
81-
return DOMAIN_PATTERN.test(bucketName) && !IP_ADDRESS_PATTERN.test(bucketName) && !DOTS_PATTERN.test(bucketName);
82-
}
83-
84-
function partitionSuffix(hostname: string): [string, string] {
85-
const parts = hostname.match(S3_HOSTNAME_PATTERN);
99+
const isDnsCompatibleBucketName = (bucketName: string): boolean =>
100+
DOMAIN_PATTERN.test(bucketName) && !IP_ADDRESS_PATTERN.test(bucketName) && !DOTS_PATTERN.test(bucketName);
86101

102+
const partitionSuffix = (hostname: string): [string, string] => {
103+
const parts = hostname.match(S3_HOSTNAME_PATTERN)!;
87104
return [parts[2], hostname.replace(new RegExp(`^${parts[0]}`), "")];
88-
}
105+
};

packages/middleware-bucket-endpoint/src/configurations.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { LoadedConfigSelectors } from "@aws-sdk/node-config-provider";
2-
import { Provider } from "@aws-sdk/types";
2+
import { Provider, RegionInfoProvider } from "@aws-sdk/types";
33

44
export interface BucketEndpointInputConfig {
55
/**
@@ -24,15 +24,24 @@ export interface BucketEndpointInputConfig {
2424
useArnRegion?: boolean | Provider<boolean>;
2525
}
2626

27+
interface PreviouslyResolved {
28+
region: Provider<string>;
29+
regionInfoProvider: RegionInfoProvider;
30+
}
31+
2732
export interface BucketEndpointResolvedConfig {
2833
bucketEndpoint: boolean;
2934
forcePathStyle: boolean;
3035
useAccelerateEndpoint: boolean;
3136
useDualstackEndpoint: boolean;
3237
useArnRegion: Provider<boolean>;
38+
region: Provider<string>;
39+
regionInfoProvider: RegionInfoProvider;
3340
}
3441

35-
export function resolveBucketEndpointConfig<T>(input: T & BucketEndpointInputConfig): T & BucketEndpointResolvedConfig {
42+
export function resolveBucketEndpointConfig<T>(
43+
input: T & PreviouslyResolved & BucketEndpointInputConfig
44+
): T & BucketEndpointResolvedConfig {
3645
const {
3746
bucketEndpoint = false,
3847
forcePathStyle = false,

0 commit comments

Comments
 (0)