Skip to content

Commit 9cb7aa9

Browse files
committed
Redirect TLS-only endpoints to TLS if you try to use them raw
1 parent 32c5db1 commit 9cb7aa9

6 files changed

Lines changed: 138 additions & 3 deletions

File tree

src/endpoints/tls-index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type { EndpointMeta, EndpointGroup };
66

77
export interface TlsEndpoint {
88
sniPart: string;
9+
plainTextAllowed?: boolean;
910
configureCertOptions?(): CertOptions;
1011
configureTlsOptions?(tlsOptions: tls.SecureContextOptions): tls.SecureContextOptions;
1112
configureAlpnPreferences?(preferences: string[]): string[];

src/endpoints/tls/example.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { TlsEndpoint } from '../tls-index.js';
22

33
export const example: TlsEndpoint = {
44
sniPart: 'example',
5+
plainTextAllowed: true,
56
meta: {
67
path: 'example',
78
description: 'An example subdomain that serves an exact copy of the classic example.com page as the root page (instead of these docs).',

src/endpoints/tls/no-tls.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { TlsEndpoint } from '../tls-index.js';
22

33
export const noTls: TlsEndpoint = {
44
sniPart: 'no-tls',
5+
plainTextAllowed: true,
56
configureTlsOptions() {
67
throw new Error('Intentionally rejecting TLS connection');
78
},

src/http-handler.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as http from 'http';
33
import * as http2 from 'http2';
44
import { MaybePromise, StatusError } from '@httptoolkit/util';
55

6-
import { httpEndpoints } from './endpoints/endpoint-index.js';
6+
import { httpEndpoints, tlsEndpoints } from './endpoints/endpoint-index.js';
77
import { HttpRequest, HttpResponse } from './endpoints/http-index.js';
88
import { handleWebSocketUpgrade } from './ws-handler.js';
99
import { resolveEndpointChain } from './endpoint-chain.js';
@@ -93,6 +93,31 @@ function createHttpRequestHandler(options: {
9393
? url.hostname.slice(0, -options.rootDomain.length - 1)
9494
: undefined;
9595

96+
// If this is a plain HTTP request to a TLS-configuring subdomain, redirect it with
97+
// a clear message — the TLS settings would be silently ignored over plain HTTP.
98+
if (protocol === 'http' && hostnamePrefix) {
99+
const prefixParts = hostnamePrefix.includes('--')
100+
? hostnamePrefix.split('--')
101+
: hostnamePrefix.split('.');
102+
103+
const tlsOnlyParts = prefixParts.filter(part => {
104+
const endpoint = tlsEndpoints.find(e => e.sniPart === part);
105+
return endpoint && !endpoint.plainTextAllowed;
106+
});
107+
108+
if (tlsOnlyParts.length > 0) {
109+
const httpsUrl = url.href.replace(/^http:/, 'https:');
110+
res.writeHead(301, {
111+
'location': httpsUrl,
112+
'content-type': 'text/plain'
113+
});
114+
res.end(
115+
`This endpoint requires HTTPS. Redirecting to ${httpsUrl}`
116+
);
117+
return;
118+
}
119+
}
120+
96121
// Serve docs at root path for all prefixes except 'example' which has its own root handler
97122
const endpointPrefixes = ['example'];
98123
const isEndpointPrefix = hostnamePrefix && endpointPrefixes.includes(hostnamePrefix);

test/root-page.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ describe("Root page endpoint", () => {
3232
expect(body).to.include('TLS Endpoints');
3333
});
3434

35-
it("returns documentation page for prefixed hostnames too", async () => {
36-
const address = `http://http1.localhost:${serverPort}/`;
35+
it("returns documentation page for prefixed hostnames", async () => {
36+
const address = `http://no-tls.localhost:${serverPort}/`;
3737
const response = await fetch(address);
3838

3939
expect(response.status).to.equal(200);

test/tls-required.spec.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import * as net from 'net';
2+
import { expect } from 'chai';
3+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
4+
5+
import { createServer } from '../src/server.js';
6+
7+
describe("TLS-required endpoints over plain HTTP", () => {
8+
9+
let server: DestroyableServer;
10+
let serverPort: number;
11+
12+
beforeEach(async () => {
13+
server = makeDestroyable(await createServer());
14+
await new Promise<void>((resolve) => server.listen(resolve));
15+
serverPort = (server.address() as net.AddressInfo).port;
16+
});
17+
18+
afterEach(async () => {
19+
await server.destroy();
20+
});
21+
22+
describe("redirects plain HTTP to HTTPS for TLS-configuring subdomains", () => {
23+
24+
for (const subdomain of [
25+
'tls-v1-0',
26+
'tls-v1-1',
27+
'tls-v1-2',
28+
'tls-v1-3',
29+
'expired',
30+
'revoked',
31+
'self-signed',
32+
'untrusted-root',
33+
'wrong-host',
34+
'http2',
35+
'http1'
36+
]) {
37+
it(`redirects http://${subdomain}.* to HTTPS`, async () => {
38+
const response = await fetch(
39+
`http://${subdomain}.localhost:${serverPort}/status/200`,
40+
{ redirect: 'manual' }
41+
);
42+
43+
expect(response.status).to.equal(301);
44+
expect(response.headers.get('location')).to.equal(
45+
`https://${subdomain}.localhost:${serverPort}/status/200`
46+
);
47+
48+
const body = await response.text();
49+
expect(body).to.include('requires HTTPS');
50+
});
51+
}
52+
53+
});
54+
55+
it("redirects combined TLS subdomains to HTTPS", async () => {
56+
const response = await fetch(
57+
`http://tls-v1-2--expired.localhost:${serverPort}/status/200`,
58+
{ redirect: 'manual' }
59+
);
60+
61+
expect(response.status).to.equal(301);
62+
expect(response.headers.get('location')).to.equal(
63+
`https://tls-v1-2--expired.localhost:${serverPort}/status/200`
64+
);
65+
});
66+
67+
it("preserves the full path and query in the redirect", async () => {
68+
const response = await fetch(
69+
`http://tls-v1-2.localhost:${serverPort}/anything?foo=bar`,
70+
{ redirect: 'manual' }
71+
);
72+
73+
expect(response.status).to.equal(301);
74+
expect(response.headers.get('location')).to.equal(
75+
`https://tls-v1-2.localhost:${serverPort}/anything?foo=bar`
76+
);
77+
});
78+
79+
describe("allows plain HTTP for plainTextAllowed subdomains", () => {
80+
81+
it("allows http://example.* requests", async () => {
82+
const response = await fetch(
83+
`http://example.localhost:${serverPort}/`
84+
);
85+
86+
expect(response.status).to.equal(200);
87+
});
88+
89+
it("allows http://no-tls.* requests", async () => {
90+
const response = await fetch(
91+
`http://no-tls.localhost:${serverPort}/status/200`
92+
);
93+
94+
expect(response.status).to.equal(200);
95+
});
96+
97+
});
98+
99+
it("allows plain HTTP for requests with no subdomain", async () => {
100+
const response = await fetch(
101+
`http://localhost:${serverPort}/status/200`
102+
);
103+
104+
expect(response.status).to.equal(200);
105+
});
106+
107+
});

0 commit comments

Comments
 (0)