Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ name: docker-image
# Tag strategy. Every push always carries a :sha-<shortsha> tag for
# traceability; the other tags layer on top based on the trigger:
# - push to main → :dev + :sha-<shortsha>
# - push tag vX.Y.Z → :X.Y.Z + :X.Y + :X + :latest
# + :sha-<shortsha>
# - push tag vX.Y.Z-rc.N → :X.Y.Z-rc.N + :sha-<shortsha> (NO rolling tags)
# - push tag vX.Y.Z → :X.Y.Z + :latest + :sha-<shortsha>
# - push tag vX.Y.Z-rc.N → :X.Y.Z-rc.N + :sha-<shortsha> (NO :latest)
# - pull request → build-only (no push), sanity check
# - workflow_dispatch → :sha-<shortsha> + optional tag input
#
# A version tag is published in full only — there is no :X.Y or :X
# abbreviation. An abbreviation freezes the moment the next release ships:
# whoever pinned it keeps the old image while the tag reads like the line.
# :latest and :dev are the only moving pointers.
#
# Pre-release tags exist so a release candidate can be QA'd on the artifacts
# customers receive before anything points at it — bar the PGO difference
# noted at "Decide PGO build mode" below. They must move no rolling
# pointer: docker/metadata-action already skips :X.Y and :X for
# pre-release semver, and the guards below keep :latest and the published
# pointer: the guards below keep :latest and the published
# `openapi-latest.json` on the last stable release.

on:
Expand Down Expand Up @@ -157,16 +160,15 @@ jobs:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
${{ startsWith(github.ref, 'refs/tags/v') && format('{0}/{1}', secrets.DOCKER_REGISTRY, env.IMAGE_NAME) || '' }}
# `latest` is guarded on `!contains(ref_name, '-')` so a `-rc.N` tag
# cannot move it; metadata-action already skips {{major}}.{{minor}}
# and {{major}} for pre-release semver.
# A version tag publishes the full version only — no
# {{major}}.{{minor}} / {{major}} abbreviations, see the tag strategy
# at the top. `latest` is guarded on `!contains(ref_name, '-')` so a
# `-rc.N` tag cannot move it.
tags: |
type=raw,value=dev,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
type=ref,event=pr
type=sha,prefix=sha-,format=short
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') }}
type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }}
labels: |
Expand Down
11 changes: 5 additions & 6 deletions .github/workflows/release-draft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,17 @@ jobs:

VERSION="${TAG#v}" # v0.4.0 -> 0.4.0 (image tags carry no leading v)

# Rolling-tags sentence. docker-image.yml publishes the :X.Y (and
# :latest) tags only for stable releases; docker/metadata-action skips
# major.minor for pre-release semver, so a -rc draft must not advertise
# an :X.Y tag that never gets pushed.
# Rolling-tag sentence. A version tag is published in full only —
# there is no :X.Y or :X abbreviation — and docker-image.yml moves
# :latest for stable releases only, so a -rc draft must not advertise
# a `latest` tag that never points at it.
PKG="https://github.com/orgs/api7/packages/container/package/aisix"
case "$TAG" in
*-*)
ROLLING="Other image tags and the \`docker.io/api7/aisix\` mirror for private/offline deployments are listed on the [package page]($PKG)."
;;
*)
MINOR="${VERSION%.*}" # 0.4.0 -> 0.4
ROLLING="Rolling tags (\`$MINOR\`, \`latest\`) and the \`docker.io/api7/aisix\` mirror for private/offline deployments are listed on the [package page]($PKG)."
ROLLING="The rolling \`latest\` tag and the \`docker.io/api7/aisix\` mirror for private/offline deployments are listed on the [package page]($PKG)."
;;
esac

Expand Down
3 changes: 2 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ git push origin vX.Y.Z
Pushing the tag triggers two workflows:

- **`docker-image.yml`** builds and publishes
`ghcr.io/api7/aisix:X.Y.Z` (plus `:X.Y`, `:X`, `:latest`, `:sha-<short>`),
`ghcr.io/api7/aisix:X.Y.Z` (plus `:latest` and `:sha-<short>`; a version
tag is published in full only, with no `:X.Y` or `:X` abbreviation),
mirrors the release tag to `docker.io/api7/aisix` for private/offline
deployments, signs the images with cosign, and stamps the version into the
binary so a running gateway self-reports `X.Y.Z` (`--version`, `Server`
Expand Down
68 changes: 56 additions & 12 deletions crates/aisix-core/src/forwarded_headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@
//! does NOT apply this second tier.
//!
//! Credential slots — `authorization`, `x-api-key`, `api-key`,
//! `x-goog-api-key`, `proxy-authorization`, `cookie` — are in neither tier
//! on purpose. Handing an internal upstream the end user's own credential,
//! `x-goog-api-key`, `proxy-authorization`, `cookie`, and the AWS SigV4
//! trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` —
//! are in neither tier on purpose. Handing an internal upstream the end user's own credential,
//! in the slot that upstream already reads, is the capability's whole
//! point, and the surface that injects a gateway credential into the same
//! slot stands aside for it (see each dispatch site).
Expand Down Expand Up @@ -97,20 +98,32 @@ pub const NEVER_FORWARD_FROM_CLIENT: &[&str] = &[
/// for the same reason.
pub const NEVER_FORWARD_FROM_CLIENT_PREFIXES: &[&str] = &["x-stainless-"];

/// Headers whose value is, or stands in for, a caller credential.
/// Headers whose value is, or travels as part of, a caller credential.
///
/// Forwardable — an internal upstream reading the end user's own
/// credential in the slot it already reads is what this capability is for
/// — and the one set a forwarded value may DISPLACE a gateway-injected
/// header in. Everything else the gateway put on the request it put there
/// to make the exchange work, so a forward leaves it alone.
///
/// The `x-amz-*` trio is SigV4 material rather than a bearer token on its
/// own: `x-amz-security-token` IS a credential, and the other two are the
/// timestamp and body hash the signature is computed over, which is why
/// they belong to the same request's identity and are listed together.
/// A Bedrock upstream never sees a forwarded copy — its signer owns all
/// three and drops any supplied value — so what this entry governs is
/// every OTHER upstream, where relaying the caller's AWS session token
/// under a `"x-*"` glob would hand a third party a live AWS credential.
pub const CREDENTIAL_SLOT_HEADERS: &[&str] = &[
"api-key", // Azure OpenAI key
"authorization", // OpenAI / Anthropic / Vertex Bearer
"cookie", // session credential
"proxy-authorization", // proxy auth
"x-api-key", // Anthropic raw, also OpenAI legacy proxies
"x-goog-api-key", // Gemini API key
"api-key", // Azure OpenAI key
"authorization", // OpenAI / Anthropic / Vertex Bearer
"cookie", // session credential
"proxy-authorization", // proxy auth
"x-amz-content-sha256", // AWS SigV4 body hash
"x-amz-date", // AWS SigV4 timestamp
"x-amz-security-token", // AWS SigV4 session token
"x-api-key", // Anthropic raw, also OpenAI legacy proxies
"x-goog-api-key", // Gemini API key
];

/// Trace-context headers, which a glob never sweeps in.
Expand Down Expand Up @@ -151,9 +164,10 @@ pub fn exact_match_only(name: &str) -> bool {
/// header_key` the gateway credential arrives in the route's
/// `auth_header_name`, and `identity_header` carries an end-user identity
/// the route promises to record and strip. The route schema forbids MOST
/// of the shared credential names for those two fields, and the two it
/// does allow (`api-key`, `x-goog-api-key`) are on the shared list
/// already — so the union below is what makes the rule complete, and
/// of the shared credential names for those two fields, and the ones it
/// does allow (`api-key`, `x-goog-api-key`, and the SigV4 trio) are on
/// the shared list already — so the union below is what makes the rule
/// complete, and
/// without it a `["x-*"]` pattern sweeps in exactly the header the
/// gateway just consumed to authenticate
/// the caller and relays it upstream, where it can be replayed against
Expand Down Expand Up @@ -299,6 +313,36 @@ mod tests {
}
}

/// The loop above iterates the list, so it can only ever confirm that
/// whatever is IN the list behaves — it can never notice a name that
/// dropped out. These three did: `x-amz-security-token`, `x-amz-date`
/// and `x-amz-content-sha256` were blocked outright before the
/// forwarding rules moved into this module, and came out of the split
/// naming no list at all, which left a `["x-*"]` pattern relaying the
/// caller's AWS session token to any non-Bedrock upstream.
///
/// Spelling them out is the point: this assertion goes red if one is
/// removed again, and the loop above would not.
#[test]
fn the_aws_sigv4_trio_is_a_credential_slot() {
for name in ["x-amz-security-token", "x-amz-date", "x-amz-content-sha256"] {
assert!(
exact_match_only(name),
"{name} must need its own name — a glob reaching it hands a \
third-party upstream the caller's AWS credential",
);
assert!(!forward_pattern_admits(&["*".into()], name), "{name}");
assert!(!forward_pattern_admits(&["x-*".into()], name), "{name}");
assert!(!forward_pattern_admits(&["x-amz-*".into()], name), "{name}");
// Still a credential SLOT, not an absolute block: an operator
// who names one exactly is opting the header in, the same way
// the other six behave.
assert!(forward_pattern_admits(&[name.to_string()], name), "{name}");
assert!(client_header_forwardable(name), "{name}");
assert!(!header_forward_blocked(name), "{name}");
}
}

#[test]
fn only_a_credential_slot_displaces_a_gateway_header() {
for name in CREDENTIAL_SLOT_HEADERS {
Expand Down
14 changes: 14 additions & 0 deletions crates/aisix-core/src/models/guardrail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,20 @@ pub struct GuardrailExecution<'a> {
/// build time, so aisix-guardrails stays free of a metrics dependency.
pub trait GuardrailMetricsSink: Send + Sync + 'static {
fn record_guardrail_execution(&self, exec: &GuardrailExecution<'_>);

/// Count one bypass the PROXY performed on the chain's behalf, with
/// no member execution to carry it: a body the gateway could not give
/// the guardrails at all, which a fail-open chain lets through (#1115).
///
/// The execution-driven bypasses reach the same counter through
/// [`Self::record_guardrail_execution`], which is why this is a second
/// method rather than a synthetic execution — there is no member, no
/// kind and no duration to report, and inventing them would put a
/// phantom row in the per-execution latency histogram.
///
/// Required rather than defaulted: a sink that silently dropped these
/// would under-report exactly the traffic the counter is read to find.
fn record_guardrail_bypass(&self, reason: &str);
}

/// Content policy evaluated before or after upstream calls.
Expand Down
4 changes: 3 additions & 1 deletion crates/aisix-core/src/models/passthrough_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ pub struct PassthroughRoute {
/// keep doing so unchanged.
///
/// A credential slot — `authorization`, `proxy-authorization`,
/// `x-api-key`, `api-key`, `x-goog-api-key`, `cookie` — and
/// `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS
/// SigV4 trio `x-amz-security-token` / `x-amz-date` /
/// `x-amz-content-sha256` — and
/// `traceparent` / `tracestate` are forwarded only when a pattern
/// names them exactly. A glob such as `"*"` or `"x-*"` is a statement
/// about the operator's own headers, not consent to hand a third party
Expand Down
4 changes: 3 additions & 1 deletion crates/aisix-core/src/models/provider_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,9 @@ pub struct RequestOverrides {
/// A header named here reaches the upstream whatever the gateway would
/// otherwise do with it. Naming a credential slot — `authorization`,
/// `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`,
/// `cookie` — hands the upstream the caller's own credential in place of the
/// `cookie`, and the AWS SigV4 trio `x-amz-security-token` /
/// `x-amz-date` / `x-amz-content-sha256` — hands the upstream the
/// caller's own credential in place of the
/// one this ProviderKey would inject there, never both. That is what lets an
/// internal service that already authorizes on the end user's
/// `Authorization` keep doing so unchanged. Any OTHER header the
Expand Down
39 changes: 39 additions & 0 deletions crates/aisix-gateway/src/upstream_headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,45 @@ mod tests {
assert!(!headers.contains_key("x-goog-api-key"), "got: {headers:?}");
}

/// The AWS SigV4 headers are credential slots on a NON-Bedrock
/// upstream, where nothing else stops them. A Bedrock provider's
/// signer owns all three and drops any supplied value, so this rule is
/// the only thing standing between a `["x-*"]` pattern and the
/// caller's live AWS session token arriving at some other provider.
#[test]
fn the_aws_sigv4_trio_needs_its_own_name_not_a_glob() {
let inbound = client(&[
("x-amz-security-token", "FQoGZXIvYXdzE-caller-session"),
("x-amz-date", "20260903T120000Z"),
("x-amz-content-sha256", "e3b0c44298fc1c14"),
("x-trace-id", "operator-own-header"),
]);

// A glob reaches the operator's own header and none of the three.
let r = overrides(&[], &["x-*"]);
let mut headers = HeaderMap::new();
let ctx = UpstreamHeaderContext::from_overrides(Some(&r)).with_client_headers(&inbound);
apply_request_headers(&mut headers, &ctx);
assert_eq!(headers["x-trace-id"], "operator-own-header");
for name in ["x-amz-security-token", "x-amz-date", "x-amz-content-sha256"] {
assert!(!headers.contains_key(name), "{name} leaked: {headers:?}");
}

// Naming one exactly IS the operator's consent, and is honoured —
// this stays a credential slot, not the absolute block it used to
// be, so an upstream that genuinely reads the caller's SigV4
// headers can still be opted in.
let r = overrides(&[], &["x-amz-security-token"]);
let mut headers = HeaderMap::new();
let ctx = UpstreamHeaderContext::from_overrides(Some(&r)).with_client_headers(&inbound);
apply_request_headers(&mut headers, &ctx);
assert_eq!(
headers["x-amz-security-token"], "FQoGZXIvYXdzE-caller-session",
"an exactly-named credential slot must still forward",
);
assert!(!headers.contains_key("x-amz-date"), "got: {headers:?}");
}

#[test]
fn transport_and_gateway_namespaces_are_never_forwarded() {
let r = overrides(&[], &["*"]);
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1869,6 +1869,7 @@ mod tests {
.unwrap()
.push(exec.error_type.map(str::to_owned));
}
fn record_guardrail_bypass(&self, _reason: &str) {}
}

for (script, tag) in [
Expand Down Expand Up @@ -3814,6 +3815,7 @@ mod tests {
exec.result,
));
}
fn record_guardrail_bypass(&self, _reason: &str) {}
}

/// A sink attached via `LiveGuardrailIndex::new_with_sink` reaches every
Expand Down
Loading