inbound-tls-validation.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { describe, expect, it } from 'vitest';
  2. import { InboundFormSchema, InboundStreamFormSchema } from '@/schemas/forms/inbound-form';
  3. import { TlsCertSchema, TlsStreamSettingsSchema } from '@/schemas/protocols/security';
  4. import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
  5. import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
  6. import { inboundFromDb } from '@/lib/xray/inbound-from-db';
  7. const fileCert = { certificateFile: '/cert/server.pem', keyFile: '/cert/server.key' };
  8. const inlineCert = { certificate: ['certificate content'], key: ['private key content'] };
  9. function parseCertificates(certificates?: unknown[]) {
  10. return InboundFormSchema.safeParse({
  11. port: 443,
  12. protocol: 'vless',
  13. settings: { clients: [] },
  14. streamSettings: {
  15. network: 'tcp',
  16. tcpSettings: {},
  17. security: 'tls',
  18. tlsSettings: { certificates },
  19. },
  20. });
  21. }
  22. describe('inbound TLS certificate validation', () => {
  23. it('rejects the empty certificate seeded by the TLS editor with a useful field error', () => {
  24. const result = parseCertificates(createTlsSettingsWithDefaultCert().certificates as unknown[]);
  25. expect(result.success).toBe(false);
  26. if (result.success) return;
  27. expect(result.error.issues[0]).toMatchObject({
  28. path: ['streamSettings', 'tlsSettings', 'certificates', 0, 'certificateFile'],
  29. message: 'pages.inbounds.form.tlsCertificateRequired',
  30. });
  31. });
  32. it.each([
  33. ['missing certificates', undefined],
  34. ['empty certificates', []],
  35. ['empty row', [{}]],
  36. ['blank paths', [{ certificateFile: ' ', keyFile: '\t' }]],
  37. ['certificate path only', [{ certificateFile: fileCert.certificateFile }]],
  38. ['private key path only', [{ keyFile: fileCert.keyFile }]],
  39. ['empty content', [{ useFile: false, certificate: [], key: [] }]],
  40. ['blank content', [{ useFile: false, certificate: [' ', '\n'], key: ['\t'] }]],
  41. ['certificate content only', [{ useFile: false, certificate: inlineCert.certificate }]],
  42. ['private key content only', [{ useFile: false, key: inlineCert.key }]],
  43. ['empty file mode with stale inline content', [{ useFile: true, ...inlineCert }]],
  44. ['empty content mode with stale file paths', [{ useFile: false, ...fileCert }]],
  45. ['valid certificate followed by an empty row', [fileCert, {}]],
  46. ['verify certificate only', [{ certificateFile: '/ca.pem', usage: 'verify' }]],
  47. ['issue certificate without its key', [{ certificate: ['CA'], usage: 'issue' }]],
  48. ['empty verify certificate alongside server certificate', [fileCert, { usage: 'verify' }]],
  49. ])('rejects %s', (_name, certificates) => {
  50. expect(parseCertificates(certificates as unknown[] | undefined).success).toBe(false);
  51. });
  52. it.each([
  53. ['file certificate', [fileCert]],
  54. ['inline certificate', [inlineCert]],
  55. ['explicit file mode', [{ useFile: true, ...fileCert }]],
  56. ['explicit inline mode', [{ useFile: false, ...inlineCert }]],
  57. ['multiple certificates', [fileCert, inlineCert]],
  58. ['issuing CA with its key', [{ ...inlineCert, usage: 'issue' }]],
  59. [
  60. 'file verification CA without a key',
  61. [fileCert, { certificateFile: '/ca.pem', usage: 'verify' }],
  62. ],
  63. [
  64. 'inline verification CA without a key',
  65. [inlineCert, { certificate: ['CA'], usage: 'verify' }],
  66. ],
  67. ])('accepts %s', (_name, certificates) => {
  68. expect(parseCertificates(certificates).success).toBe(true);
  69. });
  70. it.each([true, false])('serializes only the selected mode (useFile=%s)', (useFile) => {
  71. const result = parseCertificates([{ useFile, ...fileCert, ...inlineCert }]);
  72. expect(result.success).toBe(true);
  73. if (!result.success) return;
  74. const stream = JSON.parse(formValuesToWirePayload(result.data).streamSettings);
  75. const cert = stream.tlsSettings.certificates[0];
  76. expect(cert).toMatchObject(useFile ? fileCert : inlineCert);
  77. expect(cert).not.toHaveProperty('useFile');
  78. expect(cert).not.toHaveProperty(useFile ? 'certificate' : 'certificateFile');
  79. expect(cert).not.toHaveProperty(useFile ? 'key' : 'keyFile');
  80. });
  81. it.each([
  82. ['file', { certificateFile: '/cert/ca.pem', usage: 'verify' }],
  83. ['inline', { certificate: ['CA certificate'], usage: 'verify' }],
  84. ])('preserves TLS settings when reading back a %s verification CA without a key', (_mode, ca) => {
  85. const tlsSettings = {
  86. serverName: 'tls.example.test',
  87. alpn: ['h3'],
  88. certificates: [fileCert, ca],
  89. settings: { fingerprint: 'firefox', pinnedPeerCertSha256: ['test-pin'] },
  90. };
  91. const values = InboundFormSchema.parse({
  92. port: 443,
  93. protocol: 'vless',
  94. settings: { clients: [] },
  95. streamSettings: { network: 'tcp', tcpSettings: {}, security: 'tls', tlsSettings },
  96. });
  97. const restored = inboundFromDb(formValuesToWirePayload(values));
  98. expect(restored.streamSettings).toMatchObject({ security: 'tls', tlsSettings });
  99. });
  100. it.each([undefined, 'encipherment', 'issue'])(
  101. 'keeps wire private keys required for usage=%s',
  102. (usage) => {
  103. expect(TlsCertSchema.safeParse({ certificateFile: '/cert.pem', usage }).success).toBe(false);
  104. expect(
  105. TlsCertSchema.safeParse({ certificateFile: '/cert.pem', keyFile: '', usage }).success,
  106. ).toBe(false);
  107. expect(TlsCertSchema.safeParse({ certificate: ['certificate'], usage }).success).toBe(false);
  108. },
  109. );
  110. it('applies the same certificate requirement to Hysteria TLS', () => {
  111. const stream = {
  112. network: 'hysteria',
  113. hysteriaSettings: {},
  114. security: 'tls',
  115. tlsSettings: createTlsSettingsWithDefaultCert(),
  116. };
  117. expect(InboundStreamFormSchema.safeParse(stream).success).toBe(false);
  118. expect(
  119. InboundStreamFormSchema.safeParse({ ...stream, tlsSettings: { certificates: [fileCert] } })
  120. .success,
  121. ).toBe(true);
  122. });
  123. it('keeps Reality, unsecured, transportless and outbound TLS certificate-free', () => {
  124. for (const security of [{ security: 'reality', realitySettings: {} }, { security: 'none' }]) {
  125. expect(
  126. InboundStreamFormSchema.safeParse({ network: 'tcp', tcpSettings: {}, ...security }).success,
  127. ).toBe(true);
  128. }
  129. expect(InboundStreamFormSchema.safeParse({}).success).toBe(true);
  130. expect(TlsStreamSettingsSchema.safeParse({}).success).toBe(true);
  131. });
  132. });