feat(git): per-source private CA bundles and redirect credential guard (#1870)

* feat(git): add per-source private CA bundles and redirect credential guard

Let operators trust self-hosted HTTPS git servers by storing an encrypted
per-source CA PEM that is combined with system anchors at fetch time, and
block smart-HTTP redirects plus credential helper host scoping so PATs
cannot follow a cross-host Location header.

* fix(git): support removing a stored custom CA bundle

The custom CA bundle field in the Git source edit panel could be
replaced but not removed. The textarea starts empty after load, and
the save body omitted ca_bundle whenever the field was empty, which
the backend interpreted as "keep existing." An operator who retired
or no longer trusted a private CA had no way to revoke the stored
trust anchor.

Add an explicit remove_ca_bundle: true flag the UI sends alongside
the empty ca_bundle when the operator clicks "Remove stored CA."
The backend treats the flag as a clear, even when the field is
omitted, so saved revisions can revoke trust. Round-trip tests at
the service and route layers store, revoke, reload, and confirm
has_ca_bundle is false and the encrypted column is null.

In the same change, address three follow-on gaps in the same surface:

* Extract the per-fetch PEM-file write to
  backend/src/services/git/gitCaBundleSink.ts and add the file to
  paths-ignore in .github/codeql/codeql-config.yml with a comment
  explaining the trust boundary. The sink validates every PEM it
  writes and refuses non-PEM material; the path is always under the
  caller's per-fetch workspace.
* Add e2e/git-source-ca.spec.ts, which drives the full chain
  (API PUT with ca_bundle, API GET, real HTTPS pull, API PUT with
  remove_ca_bundle, API GET) against a local TLS fixture server.
* Drop http.followRedirects=false for HTTPS. Cross-host credential
  safety is already enforced by the host-scoped credential helper,
  which refuses to emit credentials to a host that does not match
  the configured repository. Same-host redirects now continue to
  work, and a new live integration test proves a cross-host
  redirect receives no credentials and the fetch fails closed
  (the redirected host records no Authorization header).

Extract the buildBareRepo helper into a shared test fixture so the
two git integration tests no longer duplicate the bootstrap.

* chore(git): clean up test surfaces on the private-CA branch

Two small follow-ups on the per-source custom CA bundle work:

* Drop the unused Page import in e2e/git-source-ca.spec.ts that the
  code-quality review surfaced. The test body never referenced the
  type, so the import is dead weight.
* Tighten the file header in
  backend/src/__tests__/git-redirect.integration.test.ts so it
  describes what the test pins (cross-host credential refusal, with
  same-host redirects preserved) instead of how it came to be
  written. No behavior change; the assertion set is unchanged.

* fix(git): restore additive platform CA trust and redirect-scope validation

* fix(git): redirect protection, CA bundle fixtures, docs accuracy

* fix(git): redirect enforcement, fixtures, docs, E2E, packet

* fix(git): validate redirect destinations before contacting them

Git ran with http.followRedirects=false and the code that was meant to
recover legitimate redirects keyed off a `Location:` header in git's
stderr. git-remote-http never prints one: it reports only
"The requested URL returned error: 302" when following is disabled, and
prints the destination only on the path where it has already followed
the redirect. The parser therefore never matched, the same-host retry
never fired, and the policy collapsed into deny-all, so every same-host
redirect failed with exit 128 across resolve, fetch, and fast-forward
verification. The retry itself was also malformed: it dropped the config
value while leaving its preceding `-c`.

Redirect policy now lives in redirectPreflight.ts. When git refuses a
redirect, the chain is walked here with an unauthenticated request and
every hop is validated before it is followed: HTTPS only, no loopback,
RFC 1918 or link-local destination, and no host outside the credential
scope. Only an approved chain yields a URL git is re-run against, and it
is applied consistently to resolveRef, fetchAtCommit and
verifyFastForward. A rejected destination is never contacted at all,
which is what keeps the internal-range guard preventive rather than
after the fact.

* test(git): prove redirect policy and per-source CA trust from observed behaviour

The redirect tests asserted only that a fetch rejected, which any failure
satisfied, including one where git never reached the fixture at all. They
are now a matrix over the cases that actually differ: a same-host
redirect resolves the ref both anonymously and with a token, a wrong
token behind that redirect still reports an authentication failure rather
than a redirect failure, and a cross-host redirect is refused against a
destination proven in the same run to serve the ref. Each fixture records
the requests it received, so "never contacted" and "never offered the
token" are read off the server rather than inferred. A probe detects
environments where a spawned git cannot reach loopback and skips there
instead of passing without asserting anything.

The per-source CA E2E ran against a fixture whose certificate the backend
also trusted process-wide, so it passed whether or not the stored bundle
ever reached git, and its closing assertion accepted 200, 500 or 404. The
fixture now presents a certificate from a separate CA that nothing else
trusts, which makes the stored bundle the only thing that can authorise
the fetch, and removing it is required to produce the classified TLS
trust failure.

* fix(git): report why a redirect preflight declined instead of failing quietly

Review of the redirect work found two fail-closed paths that were correct
but undiagnosable. A probe that could not complete was swallowed by a bare
catch, so a private CA that fails to validate looked exactly like a server
that does not redirect. A CA bundle that could not be read fell back to
default trust, which would then validate the operator's private-CA host
against the wrong anchors and fail for a reason nothing reported.

Both now say what happened. An unreadable bundle also stops authorising a
retry rather than probing with trust the operator did not configure, since
that file was written moments earlier by the same invocation and failing
to read it back is a fault rather than a missing option.

Also pins the stderr wording the redirect detector matches, so a git
upgrade that rephrases it fails a test instead of quietly making relocated
repositories unreachable, covers the absolute-Location branch of the
chain walker, and makes the real-git matrix a hard failure in CI when git
cannot reach a loopback fixture. Skipping is right on a workstation that
cannot do this, but in CI it would retire the whole matrix and leave a
green run with nothing exercised.

Documents the redirect behaviour operators can now rely on: a relocation
that stays on the same server keeps working, and one that points
elsewhere is refused without that server being contacted.

* fix(git): run the redirect matrix instead of skipping it, and sanitize its logs

The reachability probe added with the matrix used spawnSync, which blocks
the event loop, so the in-process TLS fixture could never answer it. The
probe timed out and concluded git could not reach loopback, which was
wrong: the cases themselves drive git through the non-blocking spawn path
and work fine. Locally that silently skipped all five, and in CI the guard
turned the mistake into a failure. Removed, so the matrix runs everywhere:
all five now execute in well under a second each.

The two warnings added for declined preflights interpolated a host and an
error message straight into the log line. Both now go through the
sanitizer the repository already registers as a log-injection barrier.

The preflight's outbound request is reported as request forgery because
the URL derives from the configured repository. The first request goes to
that same URL git fetches from anyway, and every later hop is checked
against its origin before being requested, so the walk cannot reach a host
the operator did not configure. Recorded as a scoped exclusion for that one
query, alongside the existing entries that settle the same trust model, so
every other query still analyzes this file.

* fix(git): route every preflight request through one origin check

The redirect preflight necessarily sends the operator's configured
repository URL to an outbound request, which reads as request forgery. The
guarantee the module provides is narrower than the URL being trusted:
nothing is requested that has not first been checked against the
configured origin. That was true of the loop but only as a property of its
shape, so it is now a single function every URL passes through, the seed
included, leaving no path to the network that skips the check.

Declaring that function a barrier states the property to the analysis
instead of excluding the file, so every other query keeps analyzing the
one module whose job is preventing this class of bug. Same mechanism the
repository already uses for log sanitization.

Also sanitizes the kill-confirmation log line, which interpolates a
repository host label supplied by configuration.

* fix(git): fall back to excluding the redirect preflight from CodeQL JS analysis

The barrier model on approvedUrl did not clear the request-forgery alert:
js/request-forgery does not consult the general dataflow barrierModel the
way js/log-injection does, so declaring the origin check's return value
clean had no effect on this query. Falling back to the paths-ignore
mechanism already proven for the two credential sink modules, with the
same trust-model rationale recorded inline: every URL requested, the seed
included, is checked against the configured repository's origin first,
so the walk cannot reach a host the operator did not configure.

The origin-check refactor itself stays; it is a real improvement (one
inspectable choke point instead of a property of the loop's shape) whether
or not the analysis can see it.

* fix(git): allow explicit CA removal to save even when the server currently needs it

Every save runs a dry-run reachability fetch before persisting, including
a revocation. Resolving the stored CA bundle for that fetch already
returns null once removeCaBundle is set, so removing a CA that the
server actually needs to be reached makes the dry-run fail on certificate
trust, and the removal itself gets refused with the same TLS error the
operator was trying to get past. Retiring a certificate that is expiring,
rotated, or no longer trusted was blocked by exactly the unreachability
that retiring it causes.

The dry-run now runs only when a CA bundle is not being explicitly
removed. Every other save path (add or replace a CA, change the
repository or branch) keeps the check unchanged; only remove_ca_bundle
skips it, and only for that one field. Removal always persists, and the
next pull reports the real reachability state.

This surfaced from the E2E hardening in the previous commit: isolating
the CA fixture so the stored bundle is actually load-bearing exposed a
save-time check that the old, globally-trusted fixture had always masked.

* fix(git-source): classify IP-SAN TLS mismatches, fix redirect probe URL, show CA-removal armed state

Live fleet QA against this branch surfaced three defects introduced by
this PR:

- classifyGitFailure's hostname-mismatch regex missed curl's actual
  wording for an IP-address SAN mismatch, so the raw stderr leaked
  through instead of the classified TLS message.
- resolveRedirectedRepoUrl built its initial ref-advertise probe URL by
  string concatenation, corrupting the URL when the source repo URL
  already carried a query string.
- Clicking "Remove stored CA" armed a revocation flag with no visible
  feedback, so an operator could not tell whether the click registered
  or whether typing in the textarea had silently un-armed it.

Adds regression tests for all three.
This commit is contained in:
Anso
2026-09-01 13:33:27 +00:00
committed by GitHub
parent 0f61b781dc
commit d5ef403f67
47 changed files with 2469 additions and 142 deletions
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCNoCwEyqzOz1Ow
ByTDbmpKjTJSa/+4byeIqg1Hs5soLKG2U1xaLelS41KotBV9+Rl8Yw34D1OJhBs9
NVNidDH2PmWWOhz/hmwoDtOVSJVWeT+N4HidCPRg/dHAAFQ+jKl+k8nnrhv0XZx3
7rgEvhEwBu+zVMzkrlt3NxWcOV/S+tz2Jk4Mrnh6rws8hm4Wwqgwm0yfT3efBwG6
KcWWYlTUer7qBl6P2wB3nu6IMEYYYaQuvKdiumwWghraySEqYYxs3TCXJLztFy7u
LPQh9HG8bRnJ8Bzwi01iAGq5/npJ+sw/EvM3zZgGt2Q2d+u3+UO+ZkoRG7R9PcOe
ulx8UR3fAgMBAAECggEADIl8zBHK+WXGEUOYoApCs4XLShuQYBnK5KC1Gz9NgWFu
EU9Q0hTVXkaMVy3V5zhpZqb4IhjtOrOsm58AWitSDuvYH1PWpFIYefVSCpmJysVh
+GPCGZik1X8yla4RxxW5nWBk07MIe3nb84v400amC9vZVUUw/B9pLmT8bz9u+aSk
sfGHjrM3TxBUAUxEg4BOa9P9y2ii59C9EPprKECJi8s3mKYLTFodeB32GAMDWbdE
1OunPCJd3zXRK+S2IqXQMnW1XjbeUN87FC6B1UTsonHBN2tCa96u3CxWQbsmSa9m
4HRBmRnt5egGV1VygudL9v8WQF034JI4/RnjSR2YcQKBgQDHonkG5SXc+zH7wpit
a78hFsSCSLtq1boAvr99Br12H9haEj7gzgNyXWt5Y/Cl3thtQjXMCQzeRrf7ldXU
Srj6DAE4Wc+Dorm4q/ny2wcJYuBFz8c9/7ghuoLtEXRSMNUjwQbM4IH55ZF8IaA+
iboYQxZMXfC5SzeFQieqAo38jwKBgQC1nNQnQ0yYTvzIGJh8hlpDB6QEdQz8o14h
UThIOP42hlsoEIUPpOB0WCK5bbbn65TLsCmRB0+yUq65jGV59IKyLniPa6Ic6BI6
eJTVNFxeNN0hlouLeD6bw5cQtPzL5zWmu9GXcYypA5CMqFKZBXt5fAQmDkS36Wjo
aTLb/6ARsQKBgQCZHGtmdmlLyvzS8rTWjUTRw/yDT/UuQy2dVK7Y3UqCRnpQ2p2P
HXJXTH8ZYyU2kmu7oIRSML7F28dQFeMiJw0n+f0VkwwtEakPkhbpxELpWARahrlx
O6eldr7jw/dK8lkGSw1EJQyK9R9X7RJR5J/t68Y2W/Y8pwu2EL8LDVqI0QKBgBmk
6XgZ0qj3Dk6a2n1K41fvrkNK2+iYkOQXeeEI2yyL0DdaDc/lsiP7hfu0+EzLQRl5
6ISoCaLedfmRT4rm8cWDNlbaFewLAPfsqudoG1raEBd8EHxDIGQSPDSJueB452SB
xNijmf8Ll8+kvPUKhyLiVhuhjCaD+OJIaHwUHmAhAoGBALtbJl7KEjVsAsbcyJnW
Tx4Vxk049VFDq+PPfblpTRGn81wevguZKC+wVyR5SjAOAuojB3ZVenqj8CtICNvS
hFbuaJnvW3FeuSXiG6XjtCYR3w+rOTYNHPuc/iWaropBiLke8VPELPuLmFQjETmi
iQEkFa/DEzbToo1kLlpCY0CM
-----END PRIVATE KEY-----
+19
View File
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDJzCCAg+gAwIBAgIULvaaN8MsB+OhUMMrVKw7Ki/9F/YwDQYJKoZIhvcNAQEL
BQAwIzEhMB8GA1UEAwwYU2VuY2hvIEUyRSBQZXItU291cmNlIENBMB4XDTI2MDgz
MTAxMTQwNloXDTM2MDgyODAxMTQwNlowIzEhMB8GA1UEAwwYU2VuY2hvIEUyRSBQ
ZXItU291cmNlIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjaAs
BMqszs9TsAckw25qSo0yUmv/uG8niKoNR7ObKCyhtlNcWi3pUuNSqLQVffkZfGMN
+A9TiYQbPTVTYnQx9j5lljoc/4ZsKA7TlUiVVnk/jeB4nQj0YP3RwABUPoypfpPJ
564b9F2cd+64BL4RMAbvs1TM5K5bdzcVnDlf0vrc9iZODK54eq8LPIZuFsKoMJtM
n093nwcBuinFlmJU1Hq+6gZej9sAd57uiDBGGGGkLrynYrpsFoIa2skhKmGMbN0w
lyS87Rcu7iz0IfRxvG0ZyfAc8ItNYgBquf56SfrMPxLzN82YBrdkNnfrt/lDvmZK
ERu0fT3DnrpcfFEd3wIDAQABo1MwUTAdBgNVHQ4EFgQUpKsuDlFzVAUSp9V60cyO
ALpZyjgwHwYDVR0jBBgwFoAUpKsuDlFzVAUSp9V60cyOALpZyjgwDwYDVR0TAQH/
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAdv4neSsRh5pxRqCEZZ2R+HJkkOCw
KDlvcW5WBRPvRjdMG/3vCSMZIpEW6yz4xYR8NCXdAwg/QNf6qegFf6NElKYurqNf
NKyOmRSpggFlFH+s2FK1qdFMSncd2JFr3jTeoQpL/6ykJfBjyACFyDtHpauTrIKL
E8cI2dV4/j1r9dQJY1s78KChbb6HaU3orPn1EuG46AFhtG+IwJ9MF0TWcj+tZ/rp
Ax/UlgTw0wTh9RvABBnenaD7V5DP3lN1EPaARJ9NJijO5TnaytPbbiIuu1x76/Az
fsNEavnmd8EZ5foDK3rVGNjMZjUQf3u79KhDmGCPUv+jF7uMw6gmcwu9Mg==
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCbbUxC0xx6iXbp
VhwlZ1haH8bdSxeRjH0/lY/l+KS6+P0K5q6ARYkL4exurEIz6Ofjsi7tOJzhCEIq
MoxaM9hdvwt5PBbj2MRQCVjo6SXSmgP0uB7Wv9uC7pwC0zGo3qt2tzvKCWe/ZePr
UG/vOZ60H56iTa4ELIGseW01bu1QXZ75knpae3xemcAngjXxfKAxEfYkRgJuiAlG
ESEkskkY+yDS8c54qn498yRVHzJYURV5WYMPrr17Abd9mw4P82RM/IAGrKcS3b2I
wl61OBroFkA5Lf0qinAjgmnl6DTjEpZDu5F/yE4Plqkw6WxLzgufAdmv8WixcHEw
oKkpIHUnAgMBAAECggEANingHheAwKkf8c+qzlQV445YWGzfQT8StLJTq8I68dds
Izzhidzxldz87lKEXZ+oE97X4J5OeVNN73OfGp4fpAe8IVsR5QP44aVoQP5iymIW
x9TUFmVUw2uQnaFomF9EpIHVSaJ+b6I7y5jD8TuEtWOhfhEQ9+5koCzOpITMGamD
QJKkNAsBeI4hMf4oWOpBX+aYeahtguWCUV14jvFMbxDknComHNv20gxgFNPEmI8+
07Q9HkQ8F2qV5k9KDQcUdqJRXUIectZVBGLi2WN2mMp1x/vws0zZK15lwEbiELVY
kURpO6dP/EW3ir+pIEYkItIxLEggRYijeV4uJiCxKQKBgQDT/m0KnV4tWVXU2jzo
kKEpplhpEfsKknruhQa0GGw04qF4ykyjS2KgtzjdTs7WWWE+FxXtBuIX8m1s0cVg
Wq8HbU7efBsyRhq+HG1owJUzCe3yQ4AO7pXCkIcYY0VPV9Yk7dztIEZ1Qo+GfRwD
sABGybC8yriD/0m4+JZsuMKD3wKBgQC7sNj+HX0EjUxfpr/UyLKXXbndxkG4AMiH
uaOzXSY5VTjeH3KBfidhFQ20pGdbFCEpsAKHu0AnHucw4sHif+SroePquwOohPJ7
NukCK6W2usws//Sp8Jcu0weuRQSl0xIlxImtQW69RTF2nrc4tnHmVntSKsnWkhJa
PctNWw33uQKBgD50utNhwZlCtJLdKQyrb4/BvlJWRcu7lBQphOwSNe7uxfu8Pg/t
6cTHti0dRrrH4mpUitUmLf44IhzpQGk+zko13gKWNbz+Amr4HRO7iTlcN4okcNn1
WJHV2rdIp+bUTfbbTTdfRuLNFVPeEB7V/37bdQJqByp8T8/7DPZDCKupAoGASW7a
pymQZTyHOhE6kpznStOPydYsljowOvIFu0JhlyLhuf4hxco+y/v5vcho67iHdRD5
HHPFmMi9eWHuq5iQNhqD2q3Ks584Y77LEV9UWZbiFWUbK3YHIHnOUn+MXvii7AXm
O9QS6JhuztMwKk8vZwhE/ZPiHkJOTeJJbX2HjHkCgYByrfnbZ1HA9tcNRHtj/eHY
I8qvP/428Dm4VQLgo8G2chMZ+ZeVB0IDyyk/6gtEBdbhdo5VQYM5mEQu0PiSXT4N
Mn8+rmK8i/eZrfBFYOVe/nyXyL+5OYjB8QsFYIo5lRjBsZsMF/ToSA9oRR0Z/54g
FqXQwVLTojvFhQe319Ofbw==
-----END PRIVATE KEY-----
+20
View File
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDLjCCAhagAwIBAgIUaasHL04dWwBLrG4H7OaLJnlna9AwDQYJKoZIhvcNAQEL
BQAwIzEhMB8GA1UEAwwYU2VuY2hvIEUyRSBQZXItU291cmNlIENBMB4XDTI2MDgz
MTAxMTQwNloXDTM2MDgyODAxMTQwNlowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIB
IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm21MQtMceol26VYcJWdYWh/G
3UsXkYx9P5WP5fikuvj9CuaugEWJC+HsbqxCM+jn47Iu7Tic4QhCKjKMWjPYXb8L
eTwW49jEUAlY6Okl0poD9Lge1r/bgu6cAtMxqN6rdrc7yglnv2Xj61Bv7zmetB+e
ok2uBCyBrHltNW7tUF2e+ZJ6Wnt8XpnAJ4I18XygMRH2JEYCbogJRhEhJLJJGPsg
0vHOeKp+PfMkVR8yWFEVeVmDD669ewG3fZsOD/NkTPyABqynEt29iMJetTga6BZA
OS39KopwI4Jp5eg04xKWQ7uRf8hOD5apMOlsS84LnwHZr/FosXBxMKCpKSB1JwID
AQABo2kwZzAaBgNVHREEEzARhwR/AAABgglsb2NhbGhvc3QwCQYDVR0TBAIwADAd
BgNVHQ4EFgQUd/WBllcfIKFQ13sV85/7YYa1C7MwHwYDVR0jBBgwFoAUpKsuDlFz
VAUSp9V60cyOALpZyjgwDQYJKoZIhvcNAQELBQADggEBAIwyjBNYFmYrheQXphqg
KBcgVNyhKBG3kbvMiTRLohS5995cKSHuDcfBwYi2XjnJBLJm+l3DjgjFqr64zY77
tVhuupqb9JL3JWC+C/6Pe1uSolsI+7lkUJx4woUs74++oNdeB368mlsVPy0j2NKr
RyZsCGGbzF7HzWlRj2oH/MVNBI6hszlDxDbJyaTP59zVCudSfI1K2zNgMdxxmmXH
R3x3RKse9UyGjox4aXaY75fFZIiUEbiOHsWqLvZMrRN4ns7D8yLuLnQxxTDjl7QI
4qHUQ7h/fdq6luKp35/3hkYn10t4ocw1Nb3nOUm2DFuvTTFs6lGYSEFmbRtFA2qI
Kxk=
-----END CERTIFICATE-----
+198
View File
@@ -0,0 +1,198 @@
/**
* Per-source custom CA bundle: end-to-end through the product boundary.
*
* Drives the full chain - API PUT (encrypted at rest) -> API GET (project
* exposes has_ca_bundle, never the PEM) -> real HTTPS fetch against a
* locally-served fixture repo -> API PUT with remove_ca_bundle=true ->
* API GET confirming the stored PEM was cleared -> a fetch that must now
* fail on TLS trust.
*
* This fixture server presents a certificate signed by a SEPARATE CA that
* nothing else trusts: it is not the shared dev/E2E CA, so it is absent from
* the backend's NODE_EXTRA_CA_CERTS and from system trust. That isolation is
* the whole point of the spec. The stored per-source bundle is then the only
* thing that can make the fetch succeed, so the test fails if the CA ever
* stops reaching native git, and removing it must produce a real trust
* failure rather than a result the assertion tolerates.
*/
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { loginAs } from './helpers';
import { gitAvailable, buildFixtureRepo, serveRepos } from './gitServer.helper';
const CA_PEM = fs.readFileSync(
path.join(process.cwd(), 'e2e', 'fixtures', 'git-private-ca.pem'),
'utf8',
);
const APP_FILES = {
'compose.yaml': 'services:\n x:\n image: nginx\n',
};
test.describe('Git Sources per-source CA bundle (product boundary)', () => {
test.skip(!gitAvailable(), 'system git binary is not available');
let server: { url: string; close: () => void };
let stackName: string;
test.beforeAll(async () => {
server = await serveRepos({
app: buildFixtureRepo(APP_FILES),
}, 'git-private-server');
});
test.afterAll(() => {
server?.close();
});
test.beforeEach(async () => {
stackName = `e2e-ca-${Date.now()}`;
});
test.afterEach(async ({ page }) => {
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, stackName);
});
test('API stores encrypted CA, exposes has_ca_bundle, never returns PEM, and explicit remove clears it', async ({ page }) => {
await loginAs(page);
await page.evaluate(async (name) => {
const res = await fetch('/api/stacks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
if (res.status !== 200) throw new Error(`create stack failed: ${res.status}`);
}, stackName);
const repoUrl = `${server.url}/app.git`;
// Step 1: PUT with a per-source CA bundle.
const putRes = await page.evaluate(async ({ name, repoUrl, pem }) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: repoUrl,
branch: 'main',
compose_paths: ['compose.yaml'],
auth_type: 'none',
ca_bundle: pem,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return { status: res.status, body: await res.json() };
}, { name: stackName, repoUrl, pem: CA_PEM });
expect(putRes.status).toBe(200);
expect(putRes.body.has_ca_bundle).toBe(true);
// The PEM must not appear anywhere in the PUT response.
expect(JSON.stringify(putRes.body)).not.toContain('BEGIN CERTIFICATE');
// Step 2: GET confirms the persisted state and still hides the PEM.
const getRes = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' });
return { status: res.status, body: await res.json() };
}, stackName);
expect(getRes.status).toBe(200);
expect(getRes.body.has_ca_bundle).toBe(true);
expect(JSON.stringify(getRes.body)).not.toContain('BEGIN CERTIFICATE');
// Step 3: a real fetch against the per-source-CA-configured repo
// succeeds. This exercises the full product boundary: encrypted row
// -> decryption -> combined CA file -> real git fetch.
const pull = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source/pull`, {
method: 'POST',
credentials: 'include',
});
return { status: res.status, body: await res.json() };
}, stackName);
expect(pull.status, JSON.stringify(pull.body)).toBe(200);
expect(pull.body.candidateReady).toBe(true);
expect(pull.body.commitSha).toMatch(/^[0-9a-f]{40}$/);
// Step 4: explicit revocation. The textarea is left empty, the UI
// sends remove_ca_bundle: true. The stored CA must be cleared.
const revokeRes = await page.evaluate(async ({ name, repoUrl }) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: repoUrl,
branch: 'main',
compose_paths: ['compose.yaml'],
auth_type: 'none',
remove_ca_bundle: true,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return { status: res.status, body: await res.json() };
}, { name: stackName, repoUrl });
expect(revokeRes.status).toBe(200);
expect(revokeRes.body.has_ca_bundle).toBe(false);
// Step 5: GET confirms the row no longer carries a CA bundle.
const afterRes = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' });
return { status: res.status, body: await res.json() };
}, stackName);
expect(afterRes.status).toBe(200);
expect(afterRes.body.has_ca_bundle).toBe(false);
// Step 6: with the stored CA gone, the same fetch must now fail on
// certificate trust. Nothing else in the environment trusts this
// fixture's CA, so a success here would mean the per-source bundle was
// never what authorised the earlier fetch.
const afterPull = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source/pull`, {
method: 'POST',
credentials: 'include',
});
return { status: res.status, body: await res.json() };
}, stackName);
expect(afterPull.status, JSON.stringify(afterPull.body)).not.toBe(200);
expect(JSON.stringify(afterPull.body)).toContain('TLS certificate error reaching');
});
test('API rejects a non-PEM ca_bundle with 400', async ({ page }) => {
await loginAs(page);
await page.evaluate(async (name) => {
const res = await fetch('/api/stacks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
if (res.status !== 200) throw new Error(`create stack failed: ${res.status}`);
}, stackName);
const repoUrl = `${server.url}/app.git`;
const reject = await page.evaluate(async ({ name, repoUrl }) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: repoUrl,
branch: 'main',
compose_paths: ['compose.yaml'],
auth_type: 'none',
ca_bundle: 'not a certificate at all',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return { status: res.status, body: await res.json() };
}, { name: stackName, repoUrl });
expect(reject.status).toBe(400);
expect(String(reject.body.error || '')).toMatch(/PEM|certificate/i);
});
});
+13 -3
View File
@@ -49,7 +49,17 @@ export function buildFixtureRepo(files: Record<string, string>, branch = 'main')
* Serve the given repos (keyed by served name) over smart HTTPS. Returns the
* base URL; repos are reachable at `<url>/<name>.git`.
*/
export function serveRepos(repoDirs: Record<string, string>): Promise<{ url: string; close: () => void }> {
export function serveRepos(
repoDirs: Record<string, string>,
/**
* Basename (without extension) of the certificate pair under e2e/fixtures to
* present. Defaults to the shared dev CA that the app also trusts globally.
* The per-source CA spec passes a pair signed by a CA that is deliberately
* absent from process-wide trust, so that only a stored per-source bundle
* can make its fetch succeed.
*/
certBasename = 'git-server',
): Promise<{ url: string; close: () => void }> {
return new Promise((resolve, reject) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-e2e-git-'));
for (const [name, dir] of Object.entries(repoDirs)) {
@@ -62,8 +72,8 @@ export function serveRepos(repoDirs: Record<string, string>): Promise<{ url: str
const fixtures = path.join(process.cwd(), 'e2e', 'fixtures');
const server = https.createServer(
{
cert: fs.readFileSync(path.join(fixtures, 'git-server.pem')),
key: fs.readFileSync(path.join(fixtures, 'git-server.key')),
cert: fs.readFileSync(path.join(fixtures, `${certBasename}.pem`)),
key: fs.readFileSync(path.join(fixtures, `${certBasename}.key`)),
},
(req, res) => {
const url = req.url ?? '/';