Skip to content

Commit f1636a3

Browse files
committed
feat: fix auth header
1 parent 329eb67 commit f1636a3

File tree

4 files changed

+268
-92
lines changed

4 files changed

+268
-92
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- [Error Handling](#error-handling)
1515
- [Code Examples](#code-examples)
1616
- [Additional Information](#additional-information)
17+
- [AWS SigV4 Headers](#aws-sigv4-headers)
1718

1819
A CDK construct that creates an AWS Lambda Function acting as a transparent proxy to your Tailscale network.
1920

@@ -127,9 +128,29 @@ When calling the Proxy, include the following headers to specify the target mach
127128
- `ts-target-port`: The port of the Tailscale-connected machine/device.
128129
- `ts-https`: OPTIONAL, if undefined, the default behaviour is to use https when the port is 443. If specified then it
129130
will override the default behaviour.
131+
- See the remaining AWS SigV4 headers in the next section (special case for `Authorization`).
130132

131133
These `ts-` headers are removed before the request is forwarded to the target machine.
132134

135+
#### AWS SigV4 Headers
136+
137+
The proxy automatically removes AWS SigV4 headers from the incoming request and replaces them with their `ts-`
138+
prefixed counterparts when forwarding the request if they exist. This allows you to forward headers that
139+
are named the same as required by the AWS SigV4 signature request.
140+
141+
The following headers are handled:
142+
- `ts-authorization``Authorization`
143+
- `ts-x-amz-date``x-amz-date`
144+
- `ts-host``host`
145+
- `ts-x-amz-content-sha256``x-amz-content-sha256`
146+
147+
This is useful when you need to include an `Authorization` header in the request to the target machine. If
148+
you place the value directly in the `Authorization` header, it will be overwritten by the AWS SigV4 signature
149+
that the caller generates. Instead, place your authorization value in the `ts-authorization` header. The
150+
proxy will remove all `ts-` prefixed headers before forwarding the request and will correctly set the
151+
`ts-authorization` value as the `Authorization` header in the forwarded request.
152+
153+
133154
### Creating CloudWatch Tracking Metrics
134155

135156
To enable optional tracking metrics, add the following headers to your request:

src/lambda/tailscale-proxy/index.ts

Lines changed: 28 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,15 @@
1-
import * as http from 'http';
2-
import * as https from 'https';
31
import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics';
42
import {
53
APIGatewayProxyEventV2, APIGatewayProxyResultV2,
64
} from 'aws-lambda';
7-
import { SocksProxyAgent } from 'socks-proxy-agent';
5+
import { proxyHttpRequest } from './proxy-http-request';
86

9-
async function proxyHttpRequest(
10-
target: Pick<http.RequestOptions, 'hostname' | 'port'>,
11-
isHttps: boolean | undefined,
12-
request: {
13-
path: string;
14-
method: string;
15-
headers: Record<string, string>;
16-
body: string | undefined;
17-
},
18-
): Promise<APIGatewayProxyResultV2> {
19-
20-
async function requestPromise(): Promise<APIGatewayProxyResultV2> {
21-
const socksProxyAgent = new SocksProxyAgent('socks://localhost:1055');
22-
return new Promise((resolve, reject) => {
23-
const chunks: Buffer[] = [];
24-
const httpLib = isHttps == undefined ?
25-
(target.port == 443 ? https : http) :
26-
(isHttps ? https : http);
27-
const apiRequest = httpLib.request({
28-
...target,
29-
agent: socksProxyAgent,
30-
path: request.path,
31-
method: request.method,
32-
headers: request.headers,
33-
}, (res: http.IncomingMessage) => {
34-
res.on('data', (chunk: Buffer) => {
35-
chunks.push(chunk);
36-
});
37-
res.on('end', () => {
38-
const responseBody = Buffer.concat(chunks);
39-
resolve({
40-
statusCode: res.statusCode || 500,
41-
headers: res.headers as Record<string, string>,
42-
body: responseBody.toString('base64'),
43-
isBase64Encoded: true,
44-
});
45-
});
46-
res.on('error', (error: Error): void => {
47-
console.error('Error receiving response:', error);
48-
reject(error);
49-
});
50-
});
51-
52-
apiRequest.on('error', (error: Error): void => {
53-
console.error('Error sending request:', error);
54-
reject(error);
55-
});
56-
57-
if (request.body != null) {
58-
apiRequest.write(request.body);
59-
}
60-
apiRequest.end();
61-
});
62-
}
63-
64-
65-
const connectionRetryDelays = [10, 50, 100, 500, 1000, 2000, 3000];
66-
let attempt = 0;
67-
let success = false;
68-
let response: APIGatewayProxyResultV2;
69-
70-
do {
71-
try {
72-
response = await requestPromise();
73-
success = true;
74-
} catch (error) {
75-
if (error == 'Error: Socks5 proxy rejected connection - Failure' && attempt < connectionRetryDelays.length) {
76-
console.error('Error: Socks5 proxy rejected connection - Failure');
77-
console.log('Retrying in', connectionRetryDelays[attempt], 'ms');
78-
await new Promise((resolve) => setTimeout(resolve, connectionRetryDelays[attempt]));
79-
attempt++;
80-
} else {
81-
throw error;
82-
}
83-
}
84-
} while (!success && attempt < connectionRetryDelays.length);
85-
86-
if (attempt > 0) {
87-
console.log('Error: Socks5 proxy rejected connection - Failure - RESOLVED - attempt:', attempt, 'total delay time:', connectionRetryDelays.slice(0, attempt).reduce((a, b) => a + b, 0));
88-
}
89-
90-
return response!;
91-
}
7+
const AWS_SPECIFIC_HEADERS = [
8+
'authorization',
9+
'x-amz-date',
10+
'host',
11+
'x-amz-content-sha256',
12+
];
9213

9314
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
9415
let metrics: Metrics | undefined;
@@ -122,12 +43,27 @@ export async function handler(event: APIGatewayProxyEventV2): Promise<APIGateway
12243
}
12344
}
12445

125-
const targetHeaders = { ...event.headers } as Record<string, string>;
126-
delete targetHeaders['ts-target-ip'];
127-
delete targetHeaders['ts-target-port'];
128-
if (targetHeaders['ts-metric-service']) {delete targetHeaders['ts-metric-service'];}
129-
if (targetHeaders['ts-metric-dimension-name']) {delete targetHeaders['ts-metric-dimension-name'];}
130-
if (targetHeaders['ts-metric-dimension-value']) {delete targetHeaders['ts-metric-dimension-value'];}
46+
// Create target headers by filtering out AWS-specific and ts-* headers
47+
const targetHeaders: Record<string, string> = {};
48+
for (const [key, value] of Object.entries(event.headers)) {
49+
if (!key.startsWith('ts-') && !AWS_SPECIFIC_HEADERS.includes(key.toLowerCase())) {
50+
targetHeaders[key] = value as string;
51+
}
52+
}
53+
54+
// Handle AWS SigV4 header replacements if they exist
55+
if (event.headers['ts-authorization']) {
56+
targetHeaders.Authorization = event.headers['ts-authorization'] as string;
57+
}
58+
if (event.headers['ts-x-amz-date']) {
59+
targetHeaders['x-amz-date'] = event.headers['ts-x-amz-date'] as string;
60+
}
61+
if (event.headers['ts-host']) {
62+
targetHeaders.host = event.headers['ts-host'] as string;
63+
}
64+
if (event.headers['ts-x-amz-content-sha256']) {
65+
targetHeaders['x-amz-content-sha256'] = event.headers['ts-x-amz-content-sha256'] as string;
66+
}
13167

13268
const response = await proxyHttpRequest({
13369
hostname: event.headers['ts-target-ip'],
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import * as http from 'http';
2+
import * as https from 'https';
3+
import { APIGatewayProxyResultV2 } from 'aws-lambda';
4+
import { SocksProxyAgent } from 'socks-proxy-agent';
5+
6+
export async function proxyHttpRequest(
7+
target: Pick<http.RequestOptions, 'hostname' | 'port'>,
8+
isHttps: boolean | undefined,
9+
request: {
10+
path: string;
11+
method: string;
12+
headers: Record<string, string>;
13+
body: string | undefined;
14+
},
15+
): Promise<APIGatewayProxyResultV2> {
16+
async function requestPromise(): Promise<APIGatewayProxyResultV2> {
17+
const socksProxyAgent = new SocksProxyAgent('socks://localhost:1055');
18+
return new Promise((resolve, reject) => {
19+
const chunks: Buffer[] = [];
20+
const httpLib = isHttps == undefined ?
21+
(target.port == 443 ? https : http) :
22+
(isHttps ? https : http);
23+
const apiRequest = httpLib.request({
24+
...target,
25+
agent: socksProxyAgent,
26+
path: request.path,
27+
method: request.method,
28+
headers: request.headers,
29+
}, (res: http.IncomingMessage) => {
30+
res.on('data', (chunk: Buffer) => {
31+
chunks.push(chunk);
32+
});
33+
res.on('end', () => {
34+
const responseBody = Buffer.concat(chunks);
35+
resolve({
36+
statusCode: res.statusCode || 500,
37+
headers: res.headers as Record<string, string>,
38+
body: responseBody.toString('base64'),
39+
isBase64Encoded: true,
40+
});
41+
});
42+
res.on('error', (error: Error): void => {
43+
console.error('Error receiving response:', error);
44+
reject(error);
45+
});
46+
});
47+
48+
apiRequest.on('error', (error: Error): void => {
49+
console.error('Error sending request:', error);
50+
reject(error);
51+
});
52+
53+
if (request.body != null) {
54+
apiRequest.write(request.body);
55+
}
56+
apiRequest.end();
57+
});
58+
}
59+
60+
const connectionRetryDelays = [10, 50, 100, 500, 1000, 2000, 3000];
61+
let attempt = 0;
62+
let success = false;
63+
let response: APIGatewayProxyResultV2;
64+
65+
do {
66+
try {
67+
response = await requestPromise();
68+
success = true;
69+
} catch (error) {
70+
if (error == 'Error: Socks5 proxy rejected connection - Failure' && attempt < connectionRetryDelays.length) {
71+
console.error('Error: Socks5 proxy rejected connection - Failure');
72+
console.log('Retrying in', connectionRetryDelays[attempt], 'ms');
73+
await new Promise((resolve) => setTimeout(resolve, connectionRetryDelays[attempt]));
74+
attempt++;
75+
} else {
76+
throw error;
77+
}
78+
}
79+
} while (!success && attempt < connectionRetryDelays.length);
80+
81+
if (attempt > 0) {
82+
console.log('Error: Socks5 proxy rejected connection - Failure - RESOLVED - attempt:', attempt, 'total delay time:', connectionRetryDelays.slice(0, attempt).reduce((a, b) => a + b, 0));
83+
}
84+
85+
return response!;
86+
}

test/tailscale-proxy.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
2+
import { handler } from '../src/lambda/tailscale-proxy';
3+
import * as proxyHttpRequest from '../src/lambda/tailscale-proxy/proxy-http-request';
4+
5+
function createMockSigv4InvocationEvent(ip: string, port: string, method: string, path: string, body?: string,
6+
headers?: Record<string, string>): APIGatewayProxyEventV2 {
7+
return {
8+
version: '2.0',
9+
routeKey: 'ANY /{proxy+}',
10+
rawPath: path,
11+
rawQueryString: '',
12+
headers: {
13+
'ts-target-ip': ip,
14+
'ts-target-port': port,
15+
...headers,
16+
17+
// Headers needed to call the Lamba with Sigv4
18+
'Authorization': 'AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=test',
19+
'x-amz-date': 'xxx',
20+
'host': 'xxx',
21+
'x-amz-content-sha256': 'xxx',
22+
},
23+
body: body,
24+
requestContext: {
25+
http: {
26+
method: method,
27+
path: path,
28+
protocol: 'HTTP/1.1',
29+
sourceIp: '127.0.0.1',
30+
userAgent: 'test-agent',
31+
},
32+
routeKey: 'ANY /{proxy+}',
33+
stage: '$default',
34+
requestId: 'test-request-id',
35+
accountId: 'test-account-id',
36+
apiId: 'test-api-id',
37+
domainName: 'test-domain',
38+
domainPrefix: 'test',
39+
time: '2024-01-01T00:00:00Z',
40+
timeEpoch: 1704067200000,
41+
},
42+
isBase64Encoded: false,
43+
};
44+
}
45+
46+
describe('Authorization header handling', () => {
47+
48+
it('no additional auth header to target', async () => {
49+
const event = createMockSigv4InvocationEvent(
50+
'192.168.0.1',
51+
'80',
52+
'GET',
53+
'/test/path',
54+
undefined,
55+
{
56+
extra: 'extra',
57+
},
58+
);
59+
60+
const proxyHttpRequestResponse: APIGatewayProxyResultV2 = {
61+
statusCode: 200,
62+
headers: {},
63+
body: '',
64+
isBase64Encoded: false,
65+
};
66+
const proxyHttpRequestMock = jest.spyOn(proxyHttpRequest, 'proxyHttpRequest')
67+
.mockResolvedValue(proxyHttpRequestResponse);
68+
69+
const response = await handler(event);
70+
// Proxy returns the exact same data as we get from the target
71+
expect(response).toEqual(proxyHttpRequestResponse);
72+
// Ensure we call with the target expected arguments
73+
expect(proxyHttpRequestMock).toHaveBeenCalledWith(
74+
expect.objectContaining({
75+
hostname: '192.168.0.1',
76+
port: '80',
77+
}),
78+
undefined,
79+
expect.objectContaining({
80+
headers: {
81+
extra: 'extra',
82+
},
83+
}),
84+
);
85+
86+
// Restore the original implementation
87+
jest.restoreAllMocks();
88+
});
89+
90+
it('include auth header to target', async () => {
91+
const event = createMockSigv4InvocationEvent(
92+
'192.168.0.1',
93+
'80',
94+
'GET',
95+
'/test/path',
96+
undefined,
97+
{
98+
'ts-authorization': 'PASS THIS',
99+
'extra': 'extra',
100+
},
101+
);
102+
103+
const proxyHttpRequestResponse: APIGatewayProxyResultV2 = {
104+
statusCode: 200,
105+
headers: {},
106+
body: '',
107+
isBase64Encoded: false,
108+
};
109+
const proxyHttpRequestMock = jest.spyOn(proxyHttpRequest, 'proxyHttpRequest')
110+
.mockResolvedValue(proxyHttpRequestResponse);
111+
112+
const response = await handler(event);
113+
// Proxy returns the exact same data as we get from the target
114+
expect(response).toEqual(proxyHttpRequestResponse);
115+
// Ensure we call with the target expected arguments
116+
expect(proxyHttpRequestMock).toHaveBeenCalledWith(
117+
expect.objectContaining({
118+
hostname: '192.168.0.1',
119+
port: '80',
120+
}),
121+
undefined,
122+
expect.objectContaining({
123+
headers: {
124+
Authorization: 'PASS THIS',
125+
extra: 'extra',
126+
},
127+
}),
128+
);
129+
130+
// Restore the original implementation
131+
jest.restoreAllMocks();
132+
});
133+
});

0 commit comments

Comments
 (0)