mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-21 23:11:39 +02:00
Add optional authenticated review-service capabilities endpoint
This commit is contained in:
1 parent
083544bf68
commit
eaa84e3af5
4 files changed
+560
No files matched your search
@@ -0,0 +1,74 @@
|
|||||||
|
# Review service capabilities
|
||||||
|
|
||||||
|
`GET /service/v1/capabilities` is an optional server-to-server endpoint for the
|
||||||
|
`4open.artifacts/1` contract. It returns the authenticated client ID, the supported
|
||||||
|
contract version, and an empty `available` list. Linking, snapshots, protected
|
||||||
|
files, retention, publication, entitlements and usage are not advertised. Clients
|
||||||
|
must decline those workflows until their required capabilities are implemented.
|
||||||
|
|
||||||
|
The endpoint is mounted before JSON parsing, browser sessions and passport. The
|
||||||
|
entire `/service` namespace returns a typed 404 when configuration is absent.
|
||||||
|
Only the exact GET path is accepted. Query strings, browser context headers,
|
||||||
|
cookies, bodies, duplicate authentication headers and unknown paths are rejected.
|
||||||
|
Responses contain no credentials, owner identity, repository data, cookies or CORS
|
||||||
|
permission; they have `no-store` and `no-referrer` headers. No database, queue or
|
||||||
|
provider call is involved.
|
||||||
|
|
||||||
|
## Private configuration
|
||||||
|
|
||||||
|
Leave `REVIEW_SERVICE_KEYS` unset until activation is approved. If configured,
|
||||||
|
its value is a JSON object with exactly `version: 1` and `keys`. Each key has
|
||||||
|
exactly these fields:
|
||||||
|
|
||||||
|
- `clientId`: 32 lowercase hexadecimal characters.
|
||||||
|
- `keyId`: 1–64 ASCII letters, digits, underscores or hyphens.
|
||||||
|
- `tokenSHA256`: SHA-256 of the **ASCII 64-character lowercase hexadecimal bearer
|
||||||
|
token**, stored as 64 lowercase hexadecimal characters. Do not hash the decoded
|
||||||
|
token bytes and do not store a plaintext token in this registration.
|
||||||
|
- `notBefore` and `notAfter`: UTC timestamps, `YYYY-MM-DDTHH:mm:ssZ`, in 2000–2099.
|
||||||
|
The start is inclusive and the end exclusive.
|
||||||
|
|
||||||
|
Use private runtime configuration outside Git. The caller sends
|
||||||
|
`Authorization: Bearer <token>`, `X-4open-Artifact-Client-Id` and
|
||||||
|
`X-4open-Artifact-Service-Key-Id`. Keys grant only capabilities reads. Generate a
|
||||||
|
separate token for this operation and environment; never reuse GitHub credentials,
|
||||||
|
webhook secrets or future exchange credentials. The Go review client requires
|
||||||
|
HTTPS with a trusted server certificate; a deployment must provide that transport.
|
||||||
|
|
||||||
|
The registry is bounded to 64 KiB, 64 keys, 32 clients and 16 keys per client.
|
||||||
|
Duplicate client/key pairs and reused digests are rejected. Unknown fields,
|
||||||
|
plaintext secret fields, invalid timestamps and empty configuration fail startup
|
||||||
|
with a generic diagnostic. Configuration uses ordinary JSON object parsing.
|
||||||
|
Credentials are copied into a private registry and compared by constant-time
|
||||||
|
SHA-256 digest equality. Overlapping validity windows permit rotation. Remove a
|
||||||
|
key and restart to revoke it; this is not hot reloading.
|
||||||
|
|
||||||
|
Authenticated clients share 60 reads per minute across their keys. Counters are
|
||||||
|
bounded by registered clients, reset lazily, and have no timer. Limits are local
|
||||||
|
to each server process; they are not a distributed quota. A 429 response supplies
|
||||||
|
a typed failure and a bounded retry delay. This endpoint does not accept browser
|
||||||
|
session or repository-owner authority.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`mocha --no-config --no-package test/review-capabilities.test.js` passes seven
|
||||||
|
HTTP/configuration scenarios, including 60 concurrent reads, cross-client and
|
||||||
|
wrong-key rejection, raw duplicate headers, exact path checks, browser context,
|
||||||
|
expiry, overlapping keys and restart revocation. Add
|
||||||
|
`RUN_REVIEW_SERVICE_BENCHMARK=1` for a 150-request synthetic loopback measurement.
|
||||||
|
The recorded run averaged 1.708 ms, median 1.657 ms and p95 1.974 ms with fresh
|
||||||
|
HTTP connections. These are local tooling measurements, not production latency.
|
||||||
|
|
||||||
|
A local HTTPS interoperability rehearsal uses the existing Go
|
||||||
|
`artifactclient.NewCapabilitiesReader` at review source `ee07d06`. It verifies
|
||||||
|
certificate trust, accepts the empty capability response, rejects both unsupported
|
||||||
|
access policies and rejects a wrong credential. No external provider is contacted.
|
||||||
|
|
||||||
|
The isolated new module passes strict TypeScript checking. The repository-wide
|
||||||
|
check fails with the same diagnostics on unchanged base `083544b`: ES6 does not
|
||||||
|
support existing named regex groups, an existing Octokit response type is missing
|
||||||
|
`retryCount`, and the existing library target lacks `String.at`. These are retained
|
||||||
|
as baseline limitations; this change does not claim a passing full upstream build.
|
||||||
|
|
||||||
|
The endpoint is implemented and tested locally. Upstream deployment, private key
|
||||||
|
distribution, ownership consent and the remaining artifact workflows are pending.
|
||||||
@@ -38,6 +38,8 @@ import config from "../config";
|
|||||||
import { resolveTrustProxy, isCloudflareIP } from "./trustProxy";
|
import { resolveTrustProxy, isCloudflareIP } from "./trustProxy";
|
||||||
import { createLogger, serializeError } from "../core/logger";
|
import { createLogger, serializeError } from "../core/logger";
|
||||||
|
|
||||||
|
import { createReviewCapabilities } from "./service/review-capabilities";
|
||||||
|
|
||||||
const logger = createLogger("server");
|
const logger = createLogger("server");
|
||||||
|
|
||||||
// Lazily build the templated index.html on first request so the server
|
// Lazily build the templated index.html on first request so the server
|
||||||
@@ -101,6 +103,7 @@ function indexResponse(req: express.Request, res: express.Response) {
|
|||||||
export default async function start() {
|
export default async function start() {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set("query parser", "extended");
|
app.set("query parser", "extended");
|
||||||
|
app.use("/service", createReviewCapabilities(process.env.REVIEW_SERVICE_KEYS));
|
||||||
app.use("/github/app/webhook", githubAppWebhook);
|
app.use("/github/app/webhook", githubAppWebhook);
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
// Preserve the empty body used by API validation when no JSON was parsed.
|
// Preserve the empty body used by API validation when no JSON was parsed.
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
||||||
|
import { RequestHandler } from "express";
|
||||||
|
|
||||||
|
const contract = "4open.artifacts/1";
|
||||||
|
const clientPattern = /^[a-f0-9]{32}$/;
|
||||||
|
const keyPattern = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
|
const secretPattern = /^[a-f0-9]{64}$/;
|
||||||
|
type Key = { clientId: string; digest: Buffer; from: number; until: number };
|
||||||
|
|
||||||
|
function invalid(): never {
|
||||||
|
// Never include private configuration or request values in diagnostic errors.
|
||||||
|
throw new Error("Invalid REVIEW_SERVICE_KEYS configuration");
|
||||||
|
}
|
||||||
|
function record(value: unknown, fields: string[]): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
|
||||||
|
const data = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
Object.keys(data).length !== fields.length ||
|
||||||
|
fields.some((k) => !Object.prototype.hasOwnProperty.call(data, k))
|
||||||
|
)
|
||||||
|
invalid();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function timestamp(value: unknown): number {
|
||||||
|
if (
|
||||||
|
typeof value !== "string" ||
|
||||||
|
!/^20[0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test(value)
|
||||||
|
)
|
||||||
|
invalid();
|
||||||
|
const time = Date.parse(value);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(time) ||
|
||||||
|
new Date(time).toISOString() !== value.replace("Z", ".000Z")
|
||||||
|
)
|
||||||
|
invalid();
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
function registry(raw: string): Map<string, Key> {
|
||||||
|
if (Buffer.byteLength(raw, "utf8") > 65536) invalid();
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
invalid();
|
||||||
|
}
|
||||||
|
const config = record(parsed, ["version", "keys"]);
|
||||||
|
if (
|
||||||
|
config.version !== 1 ||
|
||||||
|
!Array.isArray(config.keys) ||
|
||||||
|
config.keys.length < 1 ||
|
||||||
|
config.keys.length > 64
|
||||||
|
)
|
||||||
|
invalid();
|
||||||
|
const keys = new Map<string, Key>(),
|
||||||
|
digests = new Set<string>(),
|
||||||
|
clients = new Map<string, number>();
|
||||||
|
for (const entry of config.keys) {
|
||||||
|
const k = record(entry, [
|
||||||
|
"clientId",
|
||||||
|
"keyId",
|
||||||
|
"tokenSHA256",
|
||||||
|
"notBefore",
|
||||||
|
"notAfter",
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
typeof k.clientId !== "string" ||
|
||||||
|
!clientPattern.test(k.clientId) ||
|
||||||
|
typeof k.keyId !== "string" ||
|
||||||
|
!keyPattern.test(k.keyId) ||
|
||||||
|
typeof k.tokenSHA256 !== "string" ||
|
||||||
|
!secretPattern.test(k.tokenSHA256)
|
||||||
|
)
|
||||||
|
invalid();
|
||||||
|
const from = timestamp(k.notBefore),
|
||||||
|
until = timestamp(k.notAfter);
|
||||||
|
const id = k.clientId + ":" + k.keyId;
|
||||||
|
const count = (clients.get(k.clientId) || 0) + 1;
|
||||||
|
if (
|
||||||
|
until <= from ||
|
||||||
|
keys.has(id) ||
|
||||||
|
digests.has(k.tokenSHA256) ||
|
||||||
|
count > 16
|
||||||
|
)
|
||||||
|
invalid();
|
||||||
|
clients.set(k.clientId, count);
|
||||||
|
if (clients.size > 32) invalid();
|
||||||
|
digests.add(k.tokenSHA256);
|
||||||
|
keys.set(id, {
|
||||||
|
clientId: k.clientId,
|
||||||
|
digest: Buffer.from(k.tokenSHA256, "hex"),
|
||||||
|
from,
|
||||||
|
until,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional server-to-server read-only endpoint. No browser, owner or repository authority is inferred. */
|
||||||
|
export function createReviewCapabilities(
|
||||||
|
raw?: string,
|
||||||
|
now: () => number = Date.now,
|
||||||
|
): RequestHandler {
|
||||||
|
const keys = raw === undefined ? null : registry(raw);
|
||||||
|
// One bounded counter per registered client, shared by its rotation keys.
|
||||||
|
const rates = new Map<string, { minute: number; count: number }>();
|
||||||
|
const dummyDigest = Buffer.alloc(32);
|
||||||
|
return (req, res) => {
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.setHeader("Referrer-Policy", "no-referrer");
|
||||||
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
const fail = (status: number, code: string, retry?: number) => {
|
||||||
|
if (retry) res.setHeader("Retry-After", String(retry));
|
||||||
|
res
|
||||||
|
.status(status)
|
||||||
|
.json({
|
||||||
|
contract,
|
||||||
|
code,
|
||||||
|
requestId: randomBytes(16).toString("hex"),
|
||||||
|
...(retry ? { retryAfterSeconds: retry } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (!keys || req.originalUrl !== "/service/v1/capabilities")
|
||||||
|
return fail(404, "not-found");
|
||||||
|
if (req.method !== "GET") return fail(405, "invalid-request");
|
||||||
|
// Reject browser context and bodies before authentication; never read or log them.
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"cookie",
|
||||||
|
"origin",
|
||||||
|
"referer",
|
||||||
|
"sec-fetch-site",
|
||||||
|
"sec-fetch-mode",
|
||||||
|
"sec-fetch-dest",
|
||||||
|
"sec-fetch-user",
|
||||||
|
].some((h) => req.headers[h] !== undefined)
|
||||||
|
)
|
||||||
|
return fail(403, "forbidden");
|
||||||
|
if (
|
||||||
|
req.headers["transfer-encoding"] !== undefined ||
|
||||||
|
req.headers["content-type"] !== undefined ||
|
||||||
|
(req.headers["content-length"] !== undefined &&
|
||||||
|
req.headers["content-length"] !== "0")
|
||||||
|
)
|
||||||
|
return fail(400, "invalid-request");
|
||||||
|
const one = (name: string): string | undefined => {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < req.rawHeaders.length; i += 2)
|
||||||
|
if (req.rawHeaders[i].toLowerCase() === name) count++;
|
||||||
|
const value = req.headers[name];
|
||||||
|
return count === 1 && typeof value === "string" ? value : undefined;
|
||||||
|
};
|
||||||
|
const client = one("x-4open-artifact-client-id"),
|
||||||
|
keyId = one("x-4open-artifact-service-key-id"),
|
||||||
|
authorization = one("authorization");
|
||||||
|
if (
|
||||||
|
!client ||
|
||||||
|
!clientPattern.test(client) ||
|
||||||
|
!keyId ||
|
||||||
|
!keyPattern.test(keyId) ||
|
||||||
|
!authorization ||
|
||||||
|
!/^Bearer [a-f0-9]{64}$/i.test(authorization) ||
|
||||||
|
!secretPattern.test(authorization.slice(7))
|
||||||
|
)
|
||||||
|
return fail(401, "unauthorized");
|
||||||
|
const key = keys.get(client + ":" + keyId);
|
||||||
|
const digest = createHash("sha256")
|
||||||
|
.update(authorization.slice(7), "ascii")
|
||||||
|
.digest();
|
||||||
|
const equal = timingSafeEqual(digest, key ? key.digest : dummyDigest);
|
||||||
|
const time = now();
|
||||||
|
if (
|
||||||
|
!key ||
|
||||||
|
!equal ||
|
||||||
|
!Number.isFinite(time) ||
|
||||||
|
time < key.from ||
|
||||||
|
time >= key.until
|
||||||
|
)
|
||||||
|
return fail(401, "unauthorized");
|
||||||
|
const minute = Math.floor(time / 60000);
|
||||||
|
let rate = rates.get(client);
|
||||||
|
if (!rate || rate.minute !== minute) {
|
||||||
|
rate = { minute, count: 0 };
|
||||||
|
rates.set(client, rate);
|
||||||
|
}
|
||||||
|
if (rate.count >= 60)
|
||||||
|
return fail(
|
||||||
|
429,
|
||||||
|
"rate-limited",
|
||||||
|
Math.max(1, Math.ceil((60000 - (time % 60000)) / 1000)),
|
||||||
|
);
|
||||||
|
rate.count++;
|
||||||
|
// No link, snapshot, protected-delivery or retention support exists yet.
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json({
|
||||||
|
contract,
|
||||||
|
clientId: client,
|
||||||
|
contracts: [contract],
|
||||||
|
available: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
require("ts-node/register/transpile-only");
|
||||||
|
const { expect } = require("chai");
|
||||||
|
const { createHash } = require("crypto");
|
||||||
|
const http = require("http");
|
||||||
|
const process = require("process");
|
||||||
|
const express = require("express");
|
||||||
|
const {
|
||||||
|
createReviewCapabilities,
|
||||||
|
} = require("../src/server/service/review-capabilities");
|
||||||
|
const client = "a".repeat(32),
|
||||||
|
token = "b".repeat(64);
|
||||||
|
const digest = (value) =>
|
||||||
|
createHash("sha256").update(value, "ascii").digest("hex");
|
||||||
|
const key = (overrides = {}) => ({
|
||||||
|
clientId: client,
|
||||||
|
keyId: "rotation-1",
|
||||||
|
tokenSHA256: digest(token),
|
||||||
|
notBefore: "2026-01-01T00:00:00Z",
|
||||||
|
notAfter: "2027-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const config = (keys) => JSON.stringify({ version: 1, keys });
|
||||||
|
const headers = {
|
||||||
|
"X-4open-Artifact-Client-Id": client,
|
||||||
|
"X-4open-Artifact-Service-Key-Id": "rotation-1",
|
||||||
|
Authorization: "Bearer " + token,
|
||||||
|
};
|
||||||
|
describe("review service capabilities", function () {
|
||||||
|
let server, port, time, downstream;
|
||||||
|
async function start(raw = config([key()])) {
|
||||||
|
time = Date.parse("2026-06-01T00:00:00Z");
|
||||||
|
downstream = 0;
|
||||||
|
const app = express();
|
||||||
|
app.use(
|
||||||
|
"/service",
|
||||||
|
createReviewCapabilities(raw, () => time),
|
||||||
|
);
|
||||||
|
app.use((_req, res) => {
|
||||||
|
downstream++;
|
||||||
|
res.setHeader("Set-Cookie", "browser=1");
|
||||||
|
res.send("browser");
|
||||||
|
});
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
port = server.address().port;
|
||||||
|
}
|
||||||
|
function request(options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
path: "/service/v1/capabilities",
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const parts = [];
|
||||||
|
res.on("data", (data) => parts.push(data));
|
||||||
|
res.on("end", () =>
|
||||||
|
resolve({
|
||||||
|
status: res.statusCode,
|
||||||
|
headers: res.headers,
|
||||||
|
text: Buffer.concat(parts).toString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end(options.body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
afterEach(async () => {
|
||||||
|
if (server) {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
server = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("is disabled without a registry and bypasses browser middleware", async () => {
|
||||||
|
// Explicitly omit configuration, rather than start()'s test default.
|
||||||
|
const app = express();
|
||||||
|
app.use("/service", createReviewCapabilities());
|
||||||
|
app.use((_q, r) => r.send("browser"));
|
||||||
|
server = app.listen(0, "127.0.0.1");
|
||||||
|
await new Promise((resolve) => server.once("listening", resolve));
|
||||||
|
port = server.address().port;
|
||||||
|
const result = await request();
|
||||||
|
expect(result.status).to.equal(404);
|
||||||
|
expect(result.headers["set-cookie"]).to.equal(undefined);
|
||||||
|
});
|
||||||
|
it("advertises only the supported contract, bound to the authenticated client", async () => {
|
||||||
|
await start();
|
||||||
|
const result = await request();
|
||||||
|
expect(result.status).to.equal(200);
|
||||||
|
expect(JSON.parse(result.text)).to.deep.equal({
|
||||||
|
contract: "4open.artifacts/1",
|
||||||
|
clientId: client,
|
||||||
|
contracts: ["4open.artifacts/1"],
|
||||||
|
available: [],
|
||||||
|
});
|
||||||
|
expect(result.headers["cache-control"]).to.equal("no-store");
|
||||||
|
expect(result.headers["referrer-policy"]).to.equal("no-referrer");
|
||||||
|
expect(result.headers["set-cookie"]).to.equal(undefined);
|
||||||
|
expect(result.headers["access-control-allow-origin"]).to.equal(undefined);
|
||||||
|
expect(downstream).to.equal(0);
|
||||||
|
});
|
||||||
|
it("rejects missing, wrong, duplicated and cross-client credentials without reflecting secrets", async () => {
|
||||||
|
await start();
|
||||||
|
for (const input of [
|
||||||
|
{},
|
||||||
|
{ ...headers, Authorization: "Bearer " + "c".repeat(64) },
|
||||||
|
{ ...headers, "X-4open-Artifact-Client-Id": "d".repeat(32) },
|
||||||
|
{ ...headers, "X-4open-Artifact-Service-Key-Id": "unknown" },
|
||||||
|
{
|
||||||
|
...headers,
|
||||||
|
Authorization: [headers.Authorization, headers.Authorization],
|
||||||
|
},
|
||||||
|
{ ...headers, "X-4open-Artifact-Client-Id": [client, client] },
|
||||||
|
{
|
||||||
|
...headers,
|
||||||
|
"X-4open-Artifact-Service-Key-Id": ["rotation-1", "rotation-1"],
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
const r = await request({ headers: input });
|
||||||
|
expect(r.status).to.equal(401);
|
||||||
|
const failure = JSON.parse(r.text);
|
||||||
|
expect(failure.code).to.equal("unauthorized");
|
||||||
|
expect(failure.requestId).to.match(/^[a-f0-9]{32}$/);
|
||||||
|
expect(r.text).not.to.contain(token);
|
||||||
|
expect(r.text).not.to.contain(client);
|
||||||
|
}
|
||||||
|
expect(downstream).to.equal(0);
|
||||||
|
});
|
||||||
|
it("has exact paths and rejects browser context, methods, queries and bodies", async () => {
|
||||||
|
await start();
|
||||||
|
for (const path of [
|
||||||
|
"/service/v1/capabilities/",
|
||||||
|
"/service/v1/capabilities?code=secret",
|
||||||
|
"/service/v1/%63apabilities",
|
||||||
|
"/service/v1/bindings",
|
||||||
|
"/service/V1/capabilities",
|
||||||
|
])
|
||||||
|
expect((await request({ path })).status, path).to.equal(404);
|
||||||
|
for (const method of ["POST", "PUT", "OPTIONS", "HEAD"])
|
||||||
|
expect((await request({ method })).status).to.equal(405);
|
||||||
|
for (const name of [
|
||||||
|
"Cookie",
|
||||||
|
"Origin",
|
||||||
|
"Referer",
|
||||||
|
"Sec-Fetch-Site",
|
||||||
|
"Sec-Fetch-Mode",
|
||||||
|
"Sec-Fetch-Dest",
|
||||||
|
"Sec-Fetch-User",
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
(await request({ headers: { ...headers, [name]: "browser" } })).status,
|
||||||
|
).to.equal(403);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request({
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).to.equal(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request({
|
||||||
|
headers: { ...headers, "Content-Length": "2" },
|
||||||
|
body: "{}",
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).to.equal(400);
|
||||||
|
expect(downstream).to.equal(0);
|
||||||
|
});
|
||||||
|
it("supports overlapping keys with inclusive start, exclusive expiry and restart revocation", async () => {
|
||||||
|
const other = "c".repeat(64);
|
||||||
|
await start(
|
||||||
|
config([key(), key({ keyId: "rotation-2", tokenSHA256: digest(other) })]),
|
||||||
|
);
|
||||||
|
time = Date.parse(key().notBefore) - 1;
|
||||||
|
expect((await request()).status).to.equal(401);
|
||||||
|
time++;
|
||||||
|
expect((await request()).status).to.equal(200);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request({
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
"X-4open-Artifact-Service-Key-Id": "rotation-2",
|
||||||
|
Authorization: "Bearer " + other,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).to.equal(200);
|
||||||
|
time = Date.parse(key().notAfter);
|
||||||
|
expect((await request()).status).to.equal(401);
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
server = undefined;
|
||||||
|
await start(
|
||||||
|
config([key({ keyId: "rotation-2", tokenSHA256: digest(other) })]),
|
||||||
|
);
|
||||||
|
expect((await request()).status).to.equal(401);
|
||||||
|
});
|
||||||
|
it("limits reads per client across keys and resets without allocating anonymous counters", async () => {
|
||||||
|
const other = "c".repeat(64);
|
||||||
|
await start(
|
||||||
|
config([key(), key({ keyId: "rotation-2", tokenSHA256: digest(other) })]),
|
||||||
|
);
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 60 }, () => request()),
|
||||||
|
);
|
||||||
|
expect(results.every((r) => r.status === 200)).to.equal(true);
|
||||||
|
const r = await request({
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
"X-4open-Artifact-Service-Key-Id": "rotation-2",
|
||||||
|
Authorization: "Bearer " + other,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.status).to.equal(429);
|
||||||
|
expect(r.headers["retry-after"]).to.equal("60");
|
||||||
|
expect(JSON.parse(r.text).retryAfterSeconds).to.equal(60);
|
||||||
|
time += 60000;
|
||||||
|
expect((await request()).status).to.equal(200);
|
||||||
|
});
|
||||||
|
it("validates bounded digest-only closed configuration without leaking it", () => {
|
||||||
|
const bad = [
|
||||||
|
"",
|
||||||
|
"{",
|
||||||
|
"x".repeat(65537),
|
||||||
|
"null",
|
||||||
|
"[]",
|
||||||
|
config([]),
|
||||||
|
JSON.stringify({ version: 2, keys: [key()] }),
|
||||||
|
config([key({ secret: token })]),
|
||||||
|
config([key({ tokenSHA256: token.toUpperCase() })]),
|
||||||
|
config([key(), key()]),
|
||||||
|
config([key(), key({ keyId: "other" })]),
|
||||||
|
config([key({ notAfter: key().notBefore })]),
|
||||||
|
config([key({ notBefore: "2026-02-30T00:00:00Z" })]),
|
||||||
|
config([key({ notBefore: "2026-01-01T01:00:00+01:00" })]),
|
||||||
|
config([key({ clientId: "../client" })]),
|
||||||
|
config([key({ keyId: "a:b" })]),
|
||||||
|
config(
|
||||||
|
Array.from({ length: 17 }, (_, i) =>
|
||||||
|
key({ keyId: "k" + i, tokenSHA256: digest(String(i)) }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const raw of bad)
|
||||||
|
expect(() => createReviewCapabilities(raw)).to.throw(
|
||||||
|
"Invalid REVIEW_SERVICE_KEYS configuration",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (process.env.RUN_REVIEW_SERVICE_BENCHMARK === "1")
|
||||||
|
it("records bounded loopback read performance", async () => {
|
||||||
|
await start();
|
||||||
|
const samples = [];
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
if (i % 50 === 0) time += 60000;
|
||||||
|
const begin = process.hrtime.bigint();
|
||||||
|
const r = await request();
|
||||||
|
expect(r.status).to.equal(200);
|
||||||
|
samples.push(Number(process.hrtime.bigint() - begin) / 1e6);
|
||||||
|
}
|
||||||
|
samples.sort((a, b) => a - b);
|
||||||
|
const report = {
|
||||||
|
requests: samples.length,
|
||||||
|
transport: "loopback HTTP, fresh connections, synthetic credentials",
|
||||||
|
meanMs: samples.reduce((a, b) => a + b, 0) / samples.length,
|
||||||
|
medianMs: samples[75],
|
||||||
|
p95Ms: samples[142],
|
||||||
|
providerCalls: false,
|
||||||
|
};
|
||||||
|
require("fs").writeFileSync(
|
||||||
|
"/tmp/upstream-capabilities-perf.json",
|
||||||
|
JSON.stringify(report, null, 2) + "\n",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user