@starci/hfs 4.11.15 → 4.11.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +1 -1
- package/runtime/scripts/hfs/architecture/identity.mjs +1 -1
- package/runtime/scripts/hfs/architecture/jwt-ast.mjs +14 -9
- package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +18 -8
- package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +12 -3
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +39 -17
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +8 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +121 -9
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +12 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.11.16 - 2026-10-30
|
|
4
|
+
|
|
5
|
+
- Fixed: the back-end skeleton's `OriginGuard` (`platform/http-security`, BE-AUTH-5) requires an actual allowed `Origin` header on a cookie-bearing mutation (`POST`, `PUT`, `PATCH`, `DELETE` with a `Cookie` header) and on a door or handler marked with the new `RequireOrigin()` (`http-security.decorators.ts`, metadata `REQUIRE_ORIGIN_KEY`), which covers the start of a cookie-authenticated flow before the first cookie exists. A missing, empty, opaque (`null`) or foreign `Origin` is refused with `HTTP_SECURITY_ORIGIN_REJECTED` (403), and an allowed `Referer` no longer stands in for it there; `ServerToServer` does not exempt such a request. Safe methods, and mutations without a cookie on unmarked doors, keep their rules (Referer fallback when Origin is absent, the `ServerToServer` exemption). Spec `origin.guard.spec.ts`: class and handler markers, the first sign-in, cookie-bearing requests and the preserved admission paths, split into describe groups within the function budget.
|
|
6
|
+
- Fixed: the GraphQL platform templates' specs (`templates/be/platform/graphql` `graphql-depth.policy.spec.ts` and `localize-errors.mapper.spec.ts`, `templates/be/platform/errors-graphql` `graphql-error.mapper.spec.ts`), which the first GraphQL feature copies into an app, pass the app canon: each builds its collaborators through a Nest testing module (`spec-builds-with-testing-module`: the `ERRORS_SERVICE` and `REQUEST_LOCALE` doubles, the depth limit), optional fields are typed for `exactOptionalPropertyTypes`, and the plugin describe is split under `function-line-budget`. Assertions are unchanged.
|
|
7
|
+
- Changed: R260 `BE_JWT_ALG_PINNED` (machine `jwt-policy`, `runtime/scripts/hfs/architecture/jwt-ast.mjs`, the copy eslint-canon-be 3.8.15 holds) binds the principal by an audience derived from typed configuration (an OIDC verifier such as the Keycloak client of full, or the lite Passport verifier of the app's own tokens) or, for an issuer-wide literal audience (Supabase-style), by rejecting every role other than authenticated; algorithms and the configured issuer are unchanged. The bundled `rules.yaml` states the law. Spec: `src/architecture-jwt-policy.spec.mjs` (a role-free Keycloak verifier with a configured audience is admitted, one with a literal audience refused, in both editions).
|
|
8
|
+
- Changed: the bundled canon pins name hfs 4.11.16, eslint-canon-be 3.8.15, eslint-canon-fe 9.5.17, the Grammar family 0.15.28 and test-world 1.3.2.
|
|
9
|
+
|
|
3
10
|
## 4.11.15 - 2026-10-29
|
|
4
11
|
|
|
5
12
|
- Added: `platform/database/returned-rows.policy.ts` in the skeleton of both editions, exported from the database barrel: `rowShape({ column: kind })` builds the guard of the rows a RETURNING clause answers (kinds `string`, `number`, `boolean`, `date`, `json`, and `string?`, `number?`, `boolean?`, `date?` that also admit SQL null; a column the statement does not return is refused), and `returnedRows(raw, isRow)` narrows the result of `manager.query` or the `raw` of a query builder's `execute()` to typed rows with no cast. A result that is not a list of rows (the `[rows, count]` of a bare UPDATE included) or a row the guard refuses throws the new `DATABASE_RETURNING_ROW_REJECTED` (internal) of both editions' `DatabaseErrorCode`. The policy spec proves every kind, null, a missing column and both refusals.
|
package/package.json
CHANGED
|
@@ -31,35 +31,35 @@ pins:
|
|
|
31
31
|
source: packages/event-stream/package.json
|
|
32
32
|
why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
|
|
33
33
|
'@starci/grammar':
|
|
34
|
-
version: 0.15.
|
|
34
|
+
version: 0.15.28
|
|
35
35
|
group: starci
|
|
36
36
|
install: registry
|
|
37
37
|
side: fe
|
|
38
38
|
source: packages/grammar/package.json
|
|
39
39
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
40
40
|
'@starci/eslint-canon-be':
|
|
41
|
-
version: 3.8.
|
|
41
|
+
version: 3.8.15
|
|
42
42
|
group: starci
|
|
43
43
|
install: registry
|
|
44
44
|
side: be
|
|
45
45
|
source: packages/eslint-canon-be/package.json
|
|
46
46
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
47
47
|
'@starci/eslint-canon-fe':
|
|
48
|
-
version: 9.5.
|
|
48
|
+
version: 9.5.17
|
|
49
49
|
group: starci
|
|
50
50
|
install: registry
|
|
51
51
|
side: fe
|
|
52
52
|
source: packages/eslint-canon-fe/package.json
|
|
53
53
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
54
54
|
'@starci/eslint-grammar-core':
|
|
55
|
-
version: 0.15.
|
|
55
|
+
version: 0.15.28
|
|
56
56
|
group: starci
|
|
57
57
|
install: registry
|
|
58
58
|
side: fe
|
|
59
59
|
source: packages/eslint-grammar-core/package.json
|
|
60
60
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
61
61
|
'@starci/eslint-grammar-offset-pop':
|
|
62
|
-
version: 0.15.
|
|
62
|
+
version: 0.15.28
|
|
63
63
|
group: starci
|
|
64
64
|
install: registry
|
|
65
65
|
side: fe
|
|
@@ -92,7 +92,7 @@ pins:
|
|
|
92
92
|
source: packages/jest-preset/package.json
|
|
93
93
|
why: 'the Jest preset of a back end: the world runner runs up to min(--maxWorkers, slots) world files at once, each in its own process bound to one data slot of the test world (STARCI_TEST_WORLD_SLOT), and reads test-world state protocol 2 only: it pairs exactly with @starci/test-world, and any other state file is JEST_PRESET_WORLD_PAIR_MISMATCH. The unit run writes the lcov Sonar and Codecov import (services only); the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; it ships the unit kit (mockEntityManager, fakeTransaction, fakeQueryBuilder, queueQueryBuilders, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
|
|
94
94
|
'@starci/test-world':
|
|
95
|
-
version: 1.3.
|
|
95
|
+
version: 1.3.2
|
|
96
96
|
group: starci
|
|
97
97
|
install: registry
|
|
98
98
|
side: be
|
|
@@ -106,7 +106,7 @@ pins:
|
|
|
106
106
|
source: packages/ai-router/package.json
|
|
107
107
|
why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
108
108
|
'@starci/hfs':
|
|
109
|
-
version: 4.11.
|
|
109
|
+
version: 4.11.16
|
|
110
110
|
group: starci
|
|
111
111
|
install: registry
|
|
112
112
|
side: both
|
|
@@ -4346,7 +4346,7 @@ rules:
|
|
|
4346
4346
|
- {path: packages/hfs/src/hfs-database-config.spec.mjs, editions: [lite], codes: [DB_EXTENSION_ALLOWED, DB_MIGRATION_DESTRUCTIVE, HFS_SEED_PII, DB_VAULT_WRITE]}
|
|
4347
4347
|
- id: R260
|
|
4348
4348
|
code: "BE_JWT_ALG_PINNED"
|
|
4349
|
-
law: "
|
|
4349
|
+
law: "Every back-end JWT verifier pins a nonempty literal subset of ES256, RS256 and EdDSA and obtains its issuer from typed configuration. A principal is built only after verification and is bound either by an audience read from typed configuration (an OIDC verifier such as the Keycloak of full, or the app's own Passport access tokens of lite) or, where every token of the issuer shares one audience (Supabase-style), by rejecting every role other than authenticated first, in lite and full."
|
|
4350
4350
|
kinds: [check, lint]
|
|
4351
4351
|
gates: ["pre-commit","settle","land","ci"]
|
|
4352
4352
|
failureCodes: ["BE_JWT_ALG_PINNED"]
|
|
@@ -140,7 +140,7 @@ function checkOwnJwks(input) {
|
|
|
140
140
|
return { violations, coverage: { status: 'checked', integrationFiles: integrations.length, verifyCalls: counters.verifyCalls, verifiedCalls: counters.verifiedCalls } };
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/** R260 BE_JWT_ALG_PINNED: every jose jwtVerify call pins a literal secure algorithm list
|
|
143
|
+
/** R260 BE_JWT_ALG_PINNED: every jose jwtVerify call pins a literal secure algorithm list and a configured issuer, and binds the principal by a configured audience or the authenticated role. */
|
|
144
144
|
export function checkJwtPolicy(input) {
|
|
145
145
|
const kit = machineKit(input);
|
|
146
146
|
const violations = [];
|
|
@@ -61,28 +61,28 @@ const configType = (checker, node) => {
|
|
|
61
61
|
|| /(?:Config|Options|Env)$/u.test(symbol.name)));
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
/** Trace issuer
|
|
65
|
-
const
|
|
64
|
+
/** Trace an issuer or audience to typed configuration values through local URL/string transformations. */
|
|
65
|
+
const fromConfig = (ts, checker, expression, seen = new Set()) => {
|
|
66
66
|
const node = unwrap(ts, expression);
|
|
67
67
|
if (!node || seen.has(node)) return false;
|
|
68
68
|
const next = new Set(seen).add(node);
|
|
69
69
|
if (ts.isIdentifier(node)) {
|
|
70
70
|
return declarationsOf(ts, checker, node).some(declaration => {
|
|
71
|
-
if (ts.isVariableDeclaration(declaration) && declaration.initializer) return
|
|
71
|
+
if (ts.isVariableDeclaration(declaration) && declaration.initializer) return fromConfig(ts, checker, declaration.initializer, next);
|
|
72
72
|
if (ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration)) return configType(checker, node);
|
|
73
73
|
return /(?:\.config|\.env)\.ts$|\/modules\/config\//u.test(String(declaration.getSourceFile().fileName).replaceAll('\\', '/'));
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
76
|
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
77
|
-
return
|
|
77
|
+
return fromConfig(ts, checker, node.expression, next) || declarationsOf(ts, checker, node).some(declaration =>
|
|
78
78
|
(ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration)) && !declaration.initializer && configType(checker, node));
|
|
79
79
|
}
|
|
80
|
-
if (ts.isTemplateExpression(node)) return node.templateSpans.some(span =>
|
|
80
|
+
if (ts.isTemplateExpression(node)) return node.templateSpans.some(span => fromConfig(ts, checker, span.expression, next));
|
|
81
81
|
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
82
|
-
return
|
|
82
|
+
return fromConfig(ts, checker, node.left, next) || fromConfig(ts, checker, node.right, next);
|
|
83
83
|
}
|
|
84
84
|
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
85
|
-
return
|
|
85
|
+
return fromConfig(ts, checker, node.expression, next) || node.arguments?.some(argument => fromConfig(ts, checker, argument, next));
|
|
86
86
|
}
|
|
87
87
|
return false;
|
|
88
88
|
};
|
|
@@ -201,7 +201,12 @@ export const jwtPolicyProblems = (ts, checker, call) => {
|
|
|
201
201
|
|| options?.properties.some(entry => ts.isSpreadAssignment(entry))) {
|
|
202
202
|
problems.push('BE_JWT_ALG_PINNED: algorithms must be a non-empty literal subset of ES256, RS256 and EdDSA.');
|
|
203
203
|
}
|
|
204
|
-
if (!
|
|
205
|
-
|
|
204
|
+
if (!fromConfig(ts, checker, value('issuer'))) problems.push('BE_JWT_ALG_PINNED: issuer must derive from typed configuration.');
|
|
205
|
+
// The principal is bound either by an audience of typed configuration (an OIDC client such as Keycloak, the app's own
|
|
206
|
+
// Passport tokens) or, where the audience is shared by every token of the issuer (Supabase's literal 'authenticated'),
|
|
207
|
+
// by rejecting every role other than authenticated before the principal is returned.
|
|
208
|
+
if (!fromConfig(ts, checker, value('audience')) && !rejectsOtherRoles(ts, checker, call)) {
|
|
209
|
+
problems.push('BE_JWT_ALG_PINNED: an OIDC verifier audience must derive from typed configuration, or the verified principal must reject every role other than authenticated before returning it.');
|
|
210
|
+
}
|
|
206
211
|
return problems;
|
|
207
212
|
};
|
|
@@ -1,13 +1,23 @@
|
|
|
1
|
+
import { Test } from "@nestjs/testing"
|
|
1
2
|
import { mock } from "@starci/jest-preset"
|
|
2
3
|
import { GraphQLError } from "graphql"
|
|
4
|
+
import { ERRORS_SERVICE } from "./errors.decorators"
|
|
3
5
|
import type { ErrorsService } from "./errors.service"
|
|
4
6
|
import { formatGraphqlError } from "./graphql-error.mapper"
|
|
5
7
|
|
|
6
8
|
const formatted = { message: "raw", path: ["item"], locations: [{ line: 1, column: 3 }] }
|
|
7
9
|
|
|
10
|
+
const build = async () => {
|
|
11
|
+
const double = mock<ErrorsService>()
|
|
12
|
+
const moduleRef = await Test.createTestingModule({
|
|
13
|
+
providers: [{ provide: ERRORS_SERVICE, useValue: double }],
|
|
14
|
+
}).compile()
|
|
15
|
+
return moduleRef.get<typeof double>(ERRORS_SERVICE)
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
describe("formatGraphqlError", () => {
|
|
9
|
-
it("keeps the message of a malformed operation and marks it invalid without describing it", () => {
|
|
10
|
-
const errors =
|
|
19
|
+
it("keeps the message of a malformed operation and marks it invalid without describing it", async () => {
|
|
20
|
+
const errors = await build()
|
|
11
21
|
|
|
12
22
|
expect(formatGraphqlError(errors, formatted, new GraphQLError("GRAPHQL_VALIDATION_FAILED"))).toEqual({
|
|
13
23
|
message: "raw",
|
|
@@ -18,8 +28,8 @@ describe("formatGraphqlError", () => {
|
|
|
18
28
|
expect(errors.describe).not.toHaveBeenCalled()
|
|
19
29
|
})
|
|
20
30
|
|
|
21
|
-
it("describes the original failure of a resolver and travels its code, kind and params", () => {
|
|
22
|
-
const errors =
|
|
31
|
+
it("describes the original failure of a resolver and travels its code, kind and params", async () => {
|
|
32
|
+
const errors = await build()
|
|
23
33
|
const cause = new Error("not found")
|
|
24
34
|
errors.describe.mockReturnValue({
|
|
25
35
|
code: "ITEM_NOT_FOUND",
|
|
@@ -37,8 +47,8 @@ describe("formatGraphqlError", () => {
|
|
|
37
47
|
expect(errors.describe).toHaveBeenCalledWith(cause)
|
|
38
48
|
})
|
|
39
49
|
|
|
40
|
-
it("describes a failure that is not a GraphQL error as it is", () => {
|
|
41
|
-
const errors =
|
|
50
|
+
it("describes a failure that is not a GraphQL error as it is", async () => {
|
|
51
|
+
const errors = await build()
|
|
42
52
|
const cause = new Error("boom")
|
|
43
53
|
errors.describe.mockReturnValue({ code: "ERRORS_INTERNAL", kind: "internal", status: 500, params: {} })
|
|
44
54
|
|
|
@@ -50,9 +60,9 @@ describe("formatGraphqlError", () => {
|
|
|
50
60
|
expect(errors.describe).toHaveBeenCalledWith(cause)
|
|
51
61
|
})
|
|
52
62
|
|
|
53
|
-
it("leaves out the locations and path a formatted error does not carry", () => {
|
|
63
|
+
it("leaves out the locations and path a formatted error does not carry", async () => {
|
|
54
64
|
const subject = formatGraphqlError
|
|
55
|
-
const errors =
|
|
65
|
+
const errors = await build()
|
|
56
66
|
errors.describe.mockReturnValue({ code: "ERRORS_INTERNAL", kind: "internal", status: 500, params: {} })
|
|
57
67
|
|
|
58
68
|
expect(subject(errors, { message: "raw" }, new GraphQLError("GRAPHQL_PARSE_FAILED"))).toEqual({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Test } from "@nestjs/testing"
|
|
1
2
|
import { buildSchema, parse, validate } from "graphql"
|
|
2
3
|
import { depthLimitRule } from "./graphql-depth.policy"
|
|
3
4
|
|
|
@@ -12,10 +13,17 @@ const schema = buildSchema(`
|
|
|
12
13
|
}
|
|
13
14
|
`)
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
+
const DEPTH_LIMIT = Symbol("spec.depth-limit")
|
|
17
|
+
|
|
18
|
+
const build = async () => {
|
|
19
|
+
const moduleRef = await Test.createTestingModule({ providers: [{ provide: DEPTH_LIMIT, useValue: 2 }] }).compile()
|
|
20
|
+
const maxDepth = moduleRef.get<number>(DEPTH_LIMIT)
|
|
21
|
+
return (source: string) => validate(schema, parse(source), [depthLimitRule(maxDepth)])
|
|
22
|
+
}
|
|
16
23
|
|
|
17
24
|
describe("depthLimitRule", () => {
|
|
18
|
-
it("accepts an operation nested exactly to the depth limit", () => {
|
|
25
|
+
it("accepts an operation nested exactly to the depth limit", async () => {
|
|
26
|
+
const validateDepth = await build()
|
|
19
27
|
const errors = validateDepth(`
|
|
20
28
|
query {
|
|
21
29
|
item {
|
|
@@ -27,7 +35,8 @@ describe("depthLimitRule", () => {
|
|
|
27
35
|
expect(errors).toEqual([])
|
|
28
36
|
})
|
|
29
37
|
|
|
30
|
-
it("rejects the first selection set deeper than the limit", () => {
|
|
38
|
+
it("rejects the first selection set deeper than the limit", async () => {
|
|
39
|
+
const validateDepth = await build()
|
|
31
40
|
const errors = validateDepth(`
|
|
32
41
|
query {
|
|
33
42
|
item {
|
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
import type { GraphQLRequestContext, GraphQLRequestContextWillSendResponse, GraphQLResponse } from "@apollo/server"
|
|
2
|
+
import { Test } from "@nestjs/testing"
|
|
2
3
|
import { mock } from "@starci/jest-preset"
|
|
3
4
|
import type { Request } from "express"
|
|
4
5
|
import type { GraphQLFormattedError } from "graphql"
|
|
6
|
+
import { ERRORS_SERVICE } from "@modules/platform/errors"
|
|
5
7
|
import type { ErrorsService } from "@modules/platform/errors"
|
|
8
|
+
import { REQUEST_LOCALE } from "@modules/platform/i18n"
|
|
6
9
|
import type { RequestLocale } from "@modules/platform/i18n"
|
|
7
10
|
import type { GraphqlContext } from "./graphql.contracts"
|
|
8
11
|
import { localizeError, localizeErrorsPlugin } from "./localize-errors.mapper"
|
|
9
12
|
|
|
13
|
+
const build = async () => {
|
|
14
|
+
const errorsDouble = mock<ErrorsService>()
|
|
15
|
+
const localeDouble = mock<RequestLocale>()
|
|
16
|
+
const moduleRef = await Test.createTestingModule({
|
|
17
|
+
providers: [
|
|
18
|
+
{ provide: ERRORS_SERVICE, useValue: errorsDouble },
|
|
19
|
+
{ provide: REQUEST_LOCALE, useValue: localeDouble },
|
|
20
|
+
],
|
|
21
|
+
}).compile()
|
|
22
|
+
return {
|
|
23
|
+
errors: moduleRef.get<typeof errorsDouble>(ERRORS_SERVICE),
|
|
24
|
+
requestLocale: moduleRef.get<typeof localeDouble>(REQUEST_LOCALE),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const errorWith = (extensions?: GraphQLFormattedError["extensions"]): GraphQLFormattedError =>
|
|
29
|
+
extensions === undefined ? { message: "syntax error" } : { message: "syntax error", extensions }
|
|
30
|
+
|
|
31
|
+
const acceptLanguage = (language?: string): Request["headers"] =>
|
|
32
|
+
language === undefined ? {} : { "accept-language": language }
|
|
33
|
+
|
|
10
34
|
const singleResponse = (errors?: ReadonlyArray<GraphQLFormattedError>): GraphQLResponse =>
|
|
11
35
|
mock<GraphQLResponse>({ body: { kind: "single", singleResult: errors === undefined ? {} : { errors } } })
|
|
12
36
|
|
|
@@ -33,17 +57,17 @@ const sendResponse = async (
|
|
|
33
57
|
describe("localizeError", () => {
|
|
34
58
|
it.each([undefined, {}, { code: 400 }, { code: null }, { code: false }])(
|
|
35
59
|
"returns an error without a string code unchanged (%p)",
|
|
36
|
-
(extensions) => {
|
|
37
|
-
const errors =
|
|
38
|
-
const error
|
|
60
|
+
async (extensions) => {
|
|
61
|
+
const { errors } = await build()
|
|
62
|
+
const error = errorWith(extensions)
|
|
39
63
|
|
|
40
64
|
expect(localizeError(errors, "en", error)).toBe(error)
|
|
41
65
|
expect(errors.text).not.toHaveBeenCalled()
|
|
42
66
|
},
|
|
43
67
|
)
|
|
44
68
|
|
|
45
|
-
it("replaces a coded error's message with the catalog text and keeps only string and number params", () => {
|
|
46
|
-
const errors =
|
|
69
|
+
it("replaces a coded error's message with the catalog text and keeps only string and number params", async () => {
|
|
70
|
+
const { errors } = await build()
|
|
47
71
|
errors.text.mockReturnValue("The item was not found")
|
|
48
72
|
const error: GraphQLFormattedError = {
|
|
49
73
|
message: "ITEM_NOT_FOUND",
|
|
@@ -67,8 +91,8 @@ describe("localizeError", () => {
|
|
|
67
91
|
|
|
68
92
|
it.each([undefined, null, ["i-1"], "i-1", 42, false])(
|
|
69
93
|
"interpolates no params when extensions.params is %p",
|
|
70
|
-
(params) => {
|
|
71
|
-
const errors =
|
|
94
|
+
async (params) => {
|
|
95
|
+
const { errors } = await build()
|
|
72
96
|
errors.text.mockReturnValue("The item was not found")
|
|
73
97
|
const error: GraphQLFormattedError = {
|
|
74
98
|
message: "ITEM_NOT_FOUND",
|
|
@@ -83,8 +107,7 @@ describe("localizeError", () => {
|
|
|
83
107
|
|
|
84
108
|
describe("localizeErrorsPlugin", () => {
|
|
85
109
|
it("forwards the Accept-Language preferences and localizes every coded error in the resolved locale", async () => {
|
|
86
|
-
const errors =
|
|
87
|
-
const requestLocale = mock<RequestLocale>()
|
|
110
|
+
const { errors, requestLocale } = await build()
|
|
88
111
|
requestLocale.of.mockReturnValue("vi")
|
|
89
112
|
errors.text.mockReturnValueOnce("localized: not found").mockReturnValueOnce("localized: denied")
|
|
90
113
|
const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
|
|
@@ -115,15 +138,14 @@ describe("localizeErrorsPlugin", () => {
|
|
|
115
138
|
it.each([undefined, ""])(
|
|
116
139
|
"delegates an absent or empty Accept-Language header to the locale default (%p)",
|
|
117
140
|
async (language) => {
|
|
118
|
-
const errors =
|
|
119
|
-
const requestLocale = mock<RequestLocale>()
|
|
141
|
+
const { errors, requestLocale } = await build()
|
|
120
142
|
requestLocale.of.mockReturnValue("vi")
|
|
121
143
|
errors.text.mockReturnValue("localized: not found")
|
|
122
144
|
const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
|
|
123
145
|
|
|
124
146
|
const response = singleResponse([coded])
|
|
125
147
|
|
|
126
|
-
await sendResponse(errors, requestLocale, requestContext(response,
|
|
148
|
+
await sendResponse(errors, requestLocale, requestContext(response, acceptLanguage(language)))
|
|
127
149
|
|
|
128
150
|
expect(requestLocale.of).toHaveBeenCalledWith(language)
|
|
129
151
|
expect(errors.text).toHaveBeenCalledWith("ITEM_NOT_FOUND", {}, "vi")
|
|
@@ -132,10 +154,11 @@ describe("localizeErrorsPlugin", () => {
|
|
|
132
154
|
])
|
|
133
155
|
},
|
|
134
156
|
)
|
|
157
|
+
})
|
|
135
158
|
|
|
159
|
+
describe("localizeErrorsPlugin on a response with nothing to localize", () => {
|
|
136
160
|
it("leaves a response without errors alone", async () => {
|
|
137
|
-
const errors =
|
|
138
|
-
const requestLocale = mock<RequestLocale>()
|
|
161
|
+
const { errors, requestLocale } = await build()
|
|
139
162
|
|
|
140
163
|
const response = singleResponse()
|
|
141
164
|
const body = response.body
|
|
@@ -149,14 +172,13 @@ describe("localizeErrorsPlugin", () => {
|
|
|
149
172
|
})
|
|
150
173
|
|
|
151
174
|
it("leaves an incremental response alone", async () => {
|
|
152
|
-
const errors =
|
|
153
|
-
const requestLocale = mock<RequestLocale>()
|
|
175
|
+
const { errors, requestLocale } = await build()
|
|
154
176
|
const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
|
|
155
177
|
const response = mock<GraphQLResponse>({
|
|
156
178
|
body: {
|
|
157
179
|
kind: "incremental",
|
|
158
180
|
initialResult: { errors: [coded], hasNext: true },
|
|
159
|
-
subsequentResults: mock<
|
|
181
|
+
subsequentResults: mock<AsyncIterable<never>>(),
|
|
160
182
|
},
|
|
161
183
|
})
|
|
162
184
|
const body = response.body
|
package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { SetMetadata } from "@nestjs/common"
|
|
1
2
|
import { injector } from "@modules/platform/composition"
|
|
2
|
-
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
+
import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
4
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
4
5
|
import type { WebhookSignatureService } from "./webhook-signature.service"
|
|
5
6
|
|
|
7
|
+
/** Metadata for doors that require an actual Origin, including the start of a cookie-authenticated flow. */
|
|
8
|
+
export const REQUIRE_ORIGIN_KEY = "platform.http-security.require-origin"
|
|
9
|
+
|
|
10
|
+
/** Requires an allowed Origin on mutations even before the caller has received its first cookie. */
|
|
11
|
+
export const RequireOrigin = (): DoorDecorator => SetMetadata(REQUIRE_ORIGIN_KEY, true)
|
|
12
|
+
|
|
6
13
|
/** Token of the http-security options, exported so a spec can provide it. */
|
|
7
14
|
export const HTTP_SECURITY_OPTIONS: unique symbol = Symbol("platform.http-security.options")
|
|
8
15
|
|
|
@@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core"
|
|
|
5
5
|
import { Test } from "@nestjs/testing"
|
|
6
6
|
import { mock } from "@starci/jest-preset"
|
|
7
7
|
import { HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
8
|
-
import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
|
|
8
|
+
import { HTTP_SECURITY_OPTIONS, RequireOrigin } from "./http-security.decorators"
|
|
9
9
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
10
10
|
import { OriginGuard, ServerToServer } from "./origin.guard"
|
|
11
11
|
|
|
@@ -17,16 +17,31 @@ const options: HttpSecurityOptions = {
|
|
|
17
17
|
|
|
18
18
|
class OpenDoor {}
|
|
19
19
|
|
|
20
|
+
@RequireOrigin()
|
|
21
|
+
class CookieDoor {}
|
|
22
|
+
|
|
23
|
+
class SignInDoor {
|
|
24
|
+
@RequireOrigin()
|
|
25
|
+
start(): undefined {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
class SignedDoor {}
|
|
21
31
|
ServerToServer()(SignedDoor)
|
|
22
32
|
|
|
23
|
-
const build = async (
|
|
33
|
+
const build = async (
|
|
34
|
+
method: string,
|
|
35
|
+
headers: Request["headers"],
|
|
36
|
+
door: Type<unknown> = OpenDoor,
|
|
37
|
+
handler: () => void = () => undefined,
|
|
38
|
+
) => {
|
|
24
39
|
const request = mock<Request>({ headers, method })
|
|
25
40
|
const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
|
|
26
41
|
http.getRequest.mockReturnValue(request)
|
|
27
42
|
const context = mock<ExecutionContext>()
|
|
28
43
|
context.switchToHttp.mockReturnValue(http)
|
|
29
|
-
context.getHandler.mockReturnValue(
|
|
44
|
+
context.getHandler.mockReturnValue(handler)
|
|
30
45
|
context.getClass.mockReturnValue(door)
|
|
31
46
|
const moduleRef = await Test.createTestingModule({
|
|
32
47
|
providers: [OriginGuard, Reflector, { provide: HTTP_SECURITY_OPTIONS, useValue: options }],
|
|
@@ -36,19 +51,23 @@ const build = async (method: string, headers: Request["headers"], door: Type<unk
|
|
|
36
51
|
|
|
37
52
|
describe("OriginGuard", () => {
|
|
38
53
|
it.each(["GET", "HEAD", "OPTIONS"])("allows the safe %s method without an origin", async (method) => {
|
|
39
|
-
const { guard, context } = await build(method, {})
|
|
54
|
+
const { guard, context } = await build(method, { cookie: "session=opaque" }, CookieDoor)
|
|
40
55
|
|
|
41
56
|
expect(guard.canActivate(context)).toBe(true)
|
|
42
57
|
})
|
|
43
58
|
|
|
44
59
|
it("allows a state-changing request carrying an allowed Origin header", async () => {
|
|
45
|
-
const { guard, context } = await build("POST", {
|
|
60
|
+
const { guard, context } = await build("POST", {
|
|
61
|
+
origin: "https://app.test",
|
|
62
|
+
})
|
|
46
63
|
|
|
47
64
|
expect(guard.canActivate(context)).toBe(true)
|
|
48
65
|
})
|
|
49
66
|
|
|
50
67
|
it("falls back to the origin of an allowed Referer when Origin is absent", async () => {
|
|
51
|
-
const { guard, context } = await build("PATCH", {
|
|
68
|
+
const { guard, context } = await build("PATCH", {
|
|
69
|
+
referer: "https://app.test/notes/one",
|
|
70
|
+
})
|
|
52
71
|
|
|
53
72
|
expect(guard.canActivate(context)).toBe(true)
|
|
54
73
|
})
|
|
@@ -56,12 +75,21 @@ describe("OriginGuard", () => {
|
|
|
56
75
|
it.each([
|
|
57
76
|
{ name: "no Origin and no Referer", headers: {} },
|
|
58
77
|
{ name: "a Referer that is not a URL", headers: { referer: "not a URL" } },
|
|
59
|
-
{
|
|
78
|
+
{
|
|
79
|
+
name: "an Origin outside the allowlist",
|
|
80
|
+
headers: { origin: "https://foreign.test" },
|
|
81
|
+
},
|
|
60
82
|
{ name: "the opaque null Origin", headers: { origin: "null" } },
|
|
61
|
-
{
|
|
83
|
+
{
|
|
84
|
+
name: "a Referer outside the allowlist",
|
|
85
|
+
headers: { referer: "https://foreign.test/form" },
|
|
86
|
+
},
|
|
62
87
|
{
|
|
63
88
|
name: "a foreign Origin beside an allowed Referer",
|
|
64
|
-
headers: {
|
|
89
|
+
headers: {
|
|
90
|
+
origin: "https://foreign.test",
|
|
91
|
+
referer: "https://app.test/notes/one",
|
|
92
|
+
},
|
|
65
93
|
},
|
|
66
94
|
])("refuses a state-changing request with $name", async ({ headers }) => {
|
|
67
95
|
const { guard, context } = await build("DELETE", headers)
|
|
@@ -75,3 +103,87 @@ describe("OriginGuard", () => {
|
|
|
75
103
|
expect(guard.canActivate(context)).toBe(true)
|
|
76
104
|
})
|
|
77
105
|
})
|
|
106
|
+
|
|
107
|
+
describe("OriginGuard", () => {
|
|
108
|
+
it.each(["POST", "PUT", "PATCH", "DELETE"])(
|
|
109
|
+
"allows a cookie-bearing %s with an actual allowed Origin",
|
|
110
|
+
async (method) => {
|
|
111
|
+
const { guard, context } = await build(method, {
|
|
112
|
+
origin: "https://app.test",
|
|
113
|
+
cookie: "session=opaque",
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(guard.canActivate(context)).toBe(true)
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
it.each([
|
|
121
|
+
{ name: "no Origin", headers: {} },
|
|
122
|
+
{
|
|
123
|
+
name: "an allowed Referer alone",
|
|
124
|
+
headers: { referer: "https://app.test/form" },
|
|
125
|
+
},
|
|
126
|
+
{ name: "a malformed Referer alone", headers: { referer: "not a URL" } },
|
|
127
|
+
{ name: "a foreign Origin", headers: { origin: "https://foreign.test" } },
|
|
128
|
+
{ name: "an opaque Origin", headers: { origin: "null" } },
|
|
129
|
+
{ name: "an empty Origin", headers: { origin: "" } },
|
|
130
|
+
{
|
|
131
|
+
name: "a foreign Origin beside an allowed Referer",
|
|
132
|
+
headers: {
|
|
133
|
+
origin: "https://foreign.test",
|
|
134
|
+
referer: "https://app.test/form",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
])("refuses a cookie-bearing mutation with $name", async ({ headers }) => {
|
|
138
|
+
const { guard, context } = await build("POST", {
|
|
139
|
+
...headers,
|
|
140
|
+
cookie: "session=opaque",
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe("OriginGuard", () => {
|
|
148
|
+
it.each([{}, { referer: "https://app.test/sign-in" }])(
|
|
149
|
+
"refuses a marked cookie-flow door without Origin before a cookie exists (%o)",
|
|
150
|
+
async (headers) => {
|
|
151
|
+
const { guard, context } = await build("POST", headers, CookieDoor)
|
|
152
|
+
|
|
153
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
it("requires Origin on an individually marked handler before a cookie exists", async () => {
|
|
158
|
+
const { guard, context } = await build("POST", {}, SignInDoor, SignInDoor.prototype.start)
|
|
159
|
+
|
|
160
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("admits a marked cookie-flow door with an actual allowed Origin before a cookie exists", async () => {
|
|
164
|
+
const { guard, context } = await build("POST", { origin: "https://app.test" }, CookieDoor)
|
|
165
|
+
|
|
166
|
+
expect(guard.canActivate(context)).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("refuses Referer-only admission when a server-to-server door receives a cookie", async () => {
|
|
170
|
+
const { guard, context } = await build(
|
|
171
|
+
"POST",
|
|
172
|
+
{ cookie: "session=opaque", referer: "https://app.test/form" },
|
|
173
|
+
SignedDoor,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("refuses Referer-only admission when a server-to-server handler requires Origin", async () => {
|
|
180
|
+
const { guard, context } = await build(
|
|
181
|
+
"POST",
|
|
182
|
+
{ referer: "https://app.test/form" },
|
|
183
|
+
SignedDoor,
|
|
184
|
+
SignInDoor.prototype.start,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -4,7 +4,7 @@ import type { Reflector } from "@nestjs/core"
|
|
|
4
4
|
import { InjectReflector } from "@modules/platform/composition"
|
|
5
5
|
import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
6
6
|
import { requestOf } from "./execution-request.mapper"
|
|
7
|
-
import { InjectHttpSecurityOptions } from "./http-security.decorators"
|
|
7
|
+
import { InjectHttpSecurityOptions, REQUIRE_ORIGIN_KEY } from "./http-security.decorators"
|
|
8
8
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
9
9
|
|
|
10
10
|
const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"])
|
|
@@ -26,9 +26,8 @@ const originOf = (origin: string | undefined, referer: string | undefined): stri
|
|
|
26
26
|
|
|
27
27
|
@Injectable()
|
|
28
28
|
/**
|
|
29
|
-
* The second app guard, the CSRF defence:
|
|
30
|
-
*
|
|
31
|
-
* whatever sent it. A door marked `ServerToServer` proves its caller itself and owes no origin.
|
|
29
|
+
* The second app guard, the CSRF defence: cookie-bearing mutations and doors marked RequireOrigin need an actual allowed
|
|
30
|
+
* Origin. Other mutations retain Referer fallback and the ServerToServer exemption for callers proving themselves.
|
|
32
31
|
*/
|
|
33
32
|
export class OriginGuard implements CanActivate {
|
|
34
33
|
constructor(
|
|
@@ -40,12 +39,19 @@ export class OriginGuard implements CanActivate {
|
|
|
40
39
|
canActivate(context: ExecutionContext): boolean {
|
|
41
40
|
const request = requestOf(context)
|
|
42
41
|
if (SAFE_METHODS.has(request.method)) return true
|
|
42
|
+
const requiresOrigin =
|
|
43
|
+
this.reflector.getAllAndOverride<boolean | undefined>(REQUIRE_ORIGIN_KEY, [
|
|
44
|
+
context.getHandler(),
|
|
45
|
+
context.getClass(),
|
|
46
|
+
]) === true || request.headers.cookie !== undefined
|
|
43
47
|
const exempt = this.reflector.getAllAndOverride<boolean | undefined>(SERVER_TO_SERVER_KEY, [
|
|
44
48
|
context.getHandler(),
|
|
45
49
|
context.getClass(),
|
|
46
50
|
])
|
|
47
|
-
if (exempt === true) return true
|
|
48
|
-
const origin =
|
|
51
|
+
if (!requiresOrigin && exempt === true) return true
|
|
52
|
+
const origin = requiresOrigin
|
|
53
|
+
? request.headers.origin
|
|
54
|
+
: originOf(request.headers.origin, request.headers.referer)
|
|
49
55
|
if (origin !== undefined && this.options.allowedOrigins.includes(origin)) return true
|
|
50
56
|
throw new HttpSecurityError({ code: HttpSecurityErrorCode.OriginRejected })
|
|
51
57
|
}
|