diff --git a/components/mcp/adapters/harperHttp.ts b/components/mcp/adapters/harperHttp.ts index 2e19a692db..93bee5e553 100644 --- a/components/mcp/adapters/harperHttp.ts +++ b/components/mcp/adapters/harperHttp.ts @@ -12,6 +12,7 @@ * automatically and pipes the iterable to the wire. */ import { handleMcpRequest, type McpProfile, type NormRequest, type NormResponse } from '../transport.ts'; +import { settleDeferredCredentialRejection } from '../../../security/deferredAuthentication.ts'; import { toSseStream, type SseFrameSource } from '../sse.ts'; /** @@ -56,6 +57,9 @@ export function createHarperHttpHandler(profile: McpProfile) { // WebSocket upgrades aren't ours — let the next handler take it. if (request.isWebSocket) return nextHandler(request); + const settledCredentialRejection = settleDeferredCredentialRejection(request); + if (settledCredentialRejection) return settledCredentialRejection; + const norm: NormRequest = { method: request.method, headers: normalizeHeaders(request.headers), diff --git a/integrationTests/security/deferred-credential-rejection.test.ts b/integrationTests/security/deferred-credential-rejection.test.ts new file mode 100644 index 0000000000..bc6092631c --- /dev/null +++ b/integrationTests/security/deferred-credential-rejection.test.ts @@ -0,0 +1,188 @@ +import { suite, test, before, after } from 'node:test'; +import { equal, ok } from 'node:assert'; +import { resolve } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; +import { createApiClient } from '../apiTests/utils/client.mjs'; + +const FIXTURE_PATH = resolve(import.meta.dirname, 'fixtures/deferred-credential-rejection'); +const skipSuite = process.env.HARPER_RUNTIME === 'bun' || process.platform === 'win32'; + +const WORDPRESS_BASIC = `Basic ${Buffer.from('wordpress:abcd efgh ijkl mnop qrst uvwx').toString('base64')}`; +const DOWNSTREAM_BEARER = 'Bearer eyJhbGciOiJIUzI1NiJ9.d29vLXNlc3Npb24.not-a-harper-token'; + +const APP_ROUTE = '/wp-json/wc/v3/products'; +const PROTECTED_ROUTE = '/Ledger/'; +const PUBLIC_ROUTE = '/PublicNotice/'; + +suite( + '#2418 unrecognized app-port credentials defer until route ownership', + { skip: skipSuite }, + (ctx: ContextWithHarper) => { + let client: ReturnType; + let restURL = ''; + let adminAuthorization = ''; + + async function get(pathname: string, authorization?: string, extraHeaders: Record = {}) { + const response = await fetch(`${restURL}${pathname}`, { + headers: { ...(authorization ? { Authorization: authorization } : {}), ...extraHeaders }, + }); + const text = await response.text(); + let body: any; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { status: response.status, body, text, headers: response.headers }; + } + + before(async () => { + await setupHarperWithFixture(ctx, FIXTURE_PATH, { + config: {}, + env: { + HARPER_BUILTIN_COMPONENTS: + 'deferredAuthAppCatchAll=@/integrationTests/security/fixtures/deferred-credential-rejection/appCatchAll.js', + }, + }); + client = createApiClient(ctx.harper); + restURL = ctx.harper.httpURL; + adminAuthorization = client.headers.Authorization; + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const probe = await get(PUBLIC_ROUTE, adminAuthorization); + if (probe.status !== 404) break; + await sleep(250); + } + }); + + after(async () => { + await teardownHarper(ctx); + }); + + test('the application catch-all is mounted after rest, not before it', async () => { + const owned = await get(PUBLIC_ROUTE, adminAuthorization); + equal(owned.status, 200); + ok(owned.body?.servedBy !== 'application-catch-all', 'rest must own a Harper resource route'); + + const unowned = await get(APP_ROUTE); + equal(unowned.status, 200); + equal(unowned.body.servedBy, 'application-catch-all'); + }); + + test('valid Harper Basic credentials still authenticate a protected Harper resource', async () => { + const response = await get(PROTECTED_ROUTE, adminAuthorization); + + equal(response.status, 200, `expected the admin to read ${PROTECTED_ROUTE}: ${response.text}`); + }); + + test('an unrecognized WordPress Basic credential reaches the catch-all byte-for-byte', async () => { + const response = await get(APP_ROUTE, WORDPRESS_BASIC); + + equal(response.status, 200, `expected the catch-all to answer: ${response.text}`); + equal(response.body.servedBy, 'application-catch-all'); + equal(response.body.authorization, WORDPRESS_BASIC); + equal(response.body.harperUser, null); + }); + + test('a downstream-owned Bearer token gets the same treatment as Basic', async () => { + const response = await get(APP_ROUTE, DOWNSTREAM_BEARER); + + equal(response.status, 200, `expected the catch-all to answer: ${response.text}`); + equal(response.body.authorization, DOWNSTREAM_BEARER); + equal(response.body.harperUser, null); + }); + + test('a protected Harper resource rejects an unrecognized credential instead of falling through', async () => { + const response = await get(PROTECTED_ROUTE, WORDPRESS_BASIC); + + equal(response.status, 401, `expected a generic unauthorized: ${response.text}`); + ok(response.body?.servedBy !== 'application-catch-all', 'a Harper-owned route must not reach the application'); + }); + + test('a protected Harper resource rejects an invalid Harper Bearer token', async () => { + const response = await get(PROTECTED_ROUTE, DOWNSTREAM_BEARER); + + equal(response.status, 401, `expected a generic unauthorized: ${response.text}`); + ok(response.body?.servedBy !== 'application-catch-all'); + }); + + test('an unrecognized credential never downgrades a Harper-owned route to public access', async () => { + const anonymous = await get(PUBLIC_ROUTE); + equal(anonymous.status, 200, `PublicNotice must stay anonymously readable: ${anonymous.text}`); + ok(anonymous.body?.servedBy !== 'application-catch-all'); + + const withUnknownCredential = await get(PUBLIC_ROUTE, WORDPRESS_BASIC); + equal(withUnknownCredential.status, 401, `expected a generic unauthorized: ${withUnknownCredential.text}`); + ok(withUnknownCredential.body?.servedBy !== 'application-catch-all'); + }); + + test('a request with no credentials is unaffected on both owned and unowned routes', async () => { + const owned = await get(PUBLIC_ROUTE); + equal(owned.status, 200); + + const unowned = await get(APP_ROUTE); + equal(unowned.status, 200); + equal(unowned.body.servedBy, 'application-catch-all'); + equal(unowned.body.authorization, null); + equal(unowned.body.harperUser, ctx.harper.admin.username); + }); + + test('a deferred-credential response is kept out of shared caches', async () => { + const response = await fetch(`${restURL}${APP_ROUTE}`, { headers: { Authorization: WORDPRESS_BASIC } }); + + equal(response.status, 200); + ok( + response.headers.get('vary')?.toLowerCase().includes('authorization'), + `expected Vary: Authorization, got ${response.headers.get('vary')}` + ); + ok( + /private|no-store/i.test(response.headers.get('cache-control') ?? ''), + `expected a private cache scope, got ${response.headers.get('cache-control')}` + ); + }); + + test('a rejected credential on a REST route keeps the authentication error envelope', async () => { + const response = await get(PROTECTED_ROUTE, WORDPRESS_BASIC, { Accept: 'application/json' }); + + equal(response.status, 401, `expected a generic unauthorized: ${response.text}`); + equal(response.headers.get('content-type')?.split(';')[0], 'application/json'); + equal(typeof response.body?.error, 'string', `expected an {error} body, got: ${response.text}`); + ok(response.body.title === undefined, `expected no Problem Details envelope, got: ${response.text}`); + ok(response.body.errors === undefined, `expected no GraphQL error envelope, got: ${response.text}`); + }); + + test('a rejected credential on /graphql keeps the same envelope, not GraphQL errors', async () => { + const response = await fetch(`${restURL}/graphql?query=%7B__typename%7D`, { + headers: { Authorization: WORDPRESS_BASIC, Accept: 'application/json' }, + }); + const text = await response.text(); + + equal(response.status, 401, `expected a generic unauthorized: ${text}`); + equal(response.headers.get('content-type')?.split(';')[0], 'application/json'); + const body = JSON.parse(text); + equal(typeof body.error, 'string', `expected an {error} body, got: ${text}`); + ok(body.errors === undefined, `expected no GraphQL {errors:[...]} envelope, got: ${text}`); + }); + + test('/graphql still answers an anonymous request normally', async () => { + const response = await fetch(`${restURL}/graphql?query=%7B__typename%7D`, { + headers: { Accept: 'application/json' }, + }); + + ok(response.status !== 401, `an anonymous /graphql request must not be rejected: ${await response.text()}`); + }); + + test('the operations API still rejects an unrecognized credential in place', async () => { + const response = await fetch(ctx.harper.operationsAPIURL, { + method: 'POST', + headers: { 'Authorization': WORDPRESS_BASIC, 'Content-Type': 'application/json' }, + body: JSON.stringify({ operation: 'describe_all' }), + }); + + equal(response.status, 401); + }); + } +); diff --git a/integrationTests/security/fixtures/deferred-credential-rejection/appCatchAll.js b/integrationTests/security/fixtures/deferred-credential-rejection/appCatchAll.js new file mode 100644 index 0000000000..5d94cb0213 --- /dev/null +++ b/integrationTests/security/fixtures/deferred-credential-rejection/appCatchAll.js @@ -0,0 +1,15 @@ +export function handleApplication(scope) { + scope.server.http( + async (request) => ({ + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + servedBy: 'application-catch-all', + authorization: request.headers.get('authorization') ?? null, + harperUser: request.user?.username ?? null, + pathname: request.pathname, + }), + }), + { port: 'all', after: 'rest' } + ); +} diff --git a/integrationTests/security/fixtures/deferred-credential-rejection/config.yaml b/integrationTests/security/fixtures/deferred-credential-rejection/config.yaml new file mode 100644 index 0000000000..a02f0f9526 --- /dev/null +++ b/integrationTests/security/fixtures/deferred-credential-rejection/config.yaml @@ -0,0 +1,15 @@ +# Regression fixture for #2418 — credential rejection is deferred until route ownership is known. +# +# `deferredAuthAppCatchAll` is wired in through HARPER_BUILTIN_COMPONENTS (see the test file); it +# must ALSO appear as a top-level component key here for componentLoader to load it. It registers +# an application middleware with `after: rest`, reproducing the ordering an application needs: +# authentication -> rest -> application catch-all. +graphqlSchema: + files: '*.graphql' +jsResource: + files: resources.js +rest: true +# /graphql is Harper-owned too, so the deferred rejection has to be settled there with the same +# authentication error envelope REST returns — not GraphQL's own {errors:[...]} mapping. +graphql: true +deferredAuthAppCatchAll: true diff --git a/integrationTests/security/fixtures/deferred-credential-rejection/resources.js b/integrationTests/security/fixtures/deferred-credential-rejection/resources.js new file mode 100644 index 0000000000..20120611c2 --- /dev/null +++ b/integrationTests/security/fixtures/deferred-credential-rejection/resources.js @@ -0,0 +1,5 @@ +export class PublicNotice extends tables.PublicNotice { + allowRead() { + return true; + } +} diff --git a/integrationTests/security/fixtures/deferred-credential-rejection/schema.graphql b/integrationTests/security/fixtures/deferred-credential-rejection/schema.graphql new file mode 100644 index 0000000000..9896761e74 --- /dev/null +++ b/integrationTests/security/fixtures/deferred-credential-rejection/schema.graphql @@ -0,0 +1,14 @@ +# Regression fixture for #2418. +# +# Ledger is a protected Harper-owned resource: only an authenticated principal may read it. +# PublicNotice is Harper-owned but readable anonymously, which is what makes "Harper owns it, so +# Harper decides it" testable independently of whether the route happens to be protected. +type Ledger @table @export { + id: ID @primaryKey + note: String +} + +type PublicNotice @table @export { + id: ID @primaryKey + message: String +} diff --git a/security/auth.ts b/security/auth.ts index 7b1d13778d..02c3cf2aa9 100644 --- a/security/auth.ts +++ b/security/auth.ts @@ -7,13 +7,21 @@ import { v4 as uuid } from 'uuid'; import * as env from '../utility/environment/environmentManager.ts'; import { CONFIG_PARAMS, AUTH_AUDIT_STATUS, AUTH_AUDIT_TYPES } from '../utility/hdbTerms.ts'; import harperLogger from '../utility/logging/harper_logger.ts'; -const { forComponent, AuthAuditLog } = harperLogger; +const { forComponent, AuthAuditLog, errorForLog } = harperLogger; import serverHandlers from '../server/itc/serverHandlers.js'; const { user } = serverHandlers; -import { Headers, addVaryHeader } from '../server/serverHelpers/Headers.ts'; +import { Headers, addVaryHeader, SHARED_CACHE_OPTIN, PRIVATE_SCOPE } from '../server/serverHelpers/Headers.ts'; import { convertToMS } from '../utility/common_utils.ts'; import { verifyCertificate } from './certificateVerification/index.ts'; +import { + credentialRejectionError, + deferCredentialRejection, + getDeferredCredentialRejection, + isCredentialRejection, +} from './deferredAuthentication.ts'; import { serializeMessage } from '../server/serverHelpers/contentTypes.ts'; +import { hdbErrors } from '../utility/errors/hdbError.ts'; +const { AUTHENTICATION_ERROR_MSGS, HTTP_STATUS_CODES } = hdbErrors; const authLogger = forComponent('authentication'); const { debug } = authLogger; const authEventLog = authLogger.withTag('auth-event'); @@ -50,10 +58,6 @@ const LOG_AUTH_FAILED = env.get(CONFIG_PARAMS.LOGGING_AUDITAUTHEVENTS_LOGFAILED) const DEFAULT_COOKIE_EXPIRES = 'Tue, 01 Oct 8307 19:33:20 GMT'; -// RFC 9111 cache-scope directives; boundaries on both sides so a token like `public-foo` doesn't match -const SHARED_CACHE_OPTIN = /(^|[,\s])(public|s-maxage)($|[\s,;=])/i; -const PRIVATE_SCOPE = /(^|[,\s])(private|no-store)($|[\s,;=])/i; - let authorizationCache = new Map(); server.onInvalidatedUser(() => { // TODO: Eventually we probably want to be able to invalidate individual users @@ -210,6 +214,7 @@ export async function authentication(request, nextHandler) { const strategy = authorization.slice(0, spaceIndex); const credentials = authorization.slice(spaceIndex + 1); let username, password; + let credentialRejection; try { switch (strategy) { case 'Basic': @@ -237,7 +242,10 @@ export async function authentication(request, nextHandler) { // API has its own logic for handling this status: -1, }); - } catch { + } catch (refreshError) { + // Preserve refresh-validation faults; only a tagged rejection permits falling + // back to the original operation-token rejection. + if (!isCredentialRejection(refreshError)) throw refreshError; throw error; } } @@ -246,6 +254,11 @@ export async function authentication(request, nextHandler) { throw error; } break; + default: + throw credentialRejectionError( + AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL, + HTTP_STATUS_CODES.UNAUTHORIZED + ); } } catch (err) { if (LOG_AUTH_FAILED) { @@ -256,18 +269,31 @@ export async function authentication(request, nextHandler) { } } - return applyResponseHeaders({ - status: 401, - body: serializeMessage({ error: err.message }, request), - }); + const internalFault = !isCredentialRejection(err); + if (request.isOperationsServer || internalFault) { + if (internalFault) authLogger.error('Authentication failed internally', errorForLog(err)); + return applyResponseHeaders({ + status: 401, + body: serializeMessage( + { error: internalFault ? AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL : err.message }, + request + ), + }); + } + credentialRejection = err; } - authorizationCache.set(authorization, newUser); - if (LOG_AUTH_SUCCESSFUL) authAuditLog(newUser.username, AUTH_AUDIT_STATUS.SUCCESS, strategy); - // Shallow-clone so verifyPerms's `role.permission = fullRolePerms` reassignment - // doesn't mutate the just-stored cache entry (defense-in-depth). - if (newUser?.role) { - newUser = { ...newUser, role: { ...newUser.role, permission: { ...newUser.role.permission } } }; + if (credentialRejection) { + deferCredentialRejection(request, credentialRejection, strategy); + } else { + authorizationCache.set(authorization, newUser); + if (LOG_AUTH_SUCCESSFUL && newUser != null) + authAuditLog(newUser.username, AUTH_AUDIT_STATUS.SUCCESS, strategy); + // Shallow-clone so verifyPerms's `role.permission = fullRolePerms` reassignment + // doesn't mutate the just-stored cache entry (defense-in-depth). + if (newUser?.role) { + newUser = { ...newUser, role: { ...newUser.role, permission: { ...newUser.role.permission } } }; + } } } @@ -355,16 +381,19 @@ export async function authentication(request, nextHandler) { if (!response) return response; if (response.status === 401) { wasUnauthorized = true; - if ( - headers['user-agent']?.startsWith('Mozilla') && - headers.accept?.startsWith('text/html') && - resources.loginPath - ) { - // on the web if we have a login page, default to redirecting to it - response.status = 302; - response.headers.set('Location', resources.loginPath(request)); - } // the HTTP specified way of indicating HTTP authentication methods supported: - else response.headers.set('WWW-Authenticate', 'Basic'); + // Downstream owns the challenge or redirect after Harper deferred the credential decision. + if (!getDeferredCredentialRejection(request)) { + if ( + headers['user-agent']?.startsWith('Mozilla') && + headers.accept?.startsWith('text/html') && + resources.loginPath + ) { + // on the web if we have a login page, default to redirecting to it + response.status = 302; + response.headers.set('Location', resources.loginPath(request)); + } // the HTTP specified way of indicating HTTP authentication methods supported: + else response.headers.set('WWW-Authenticate', 'Basic'); + } } return applyResponseHeaders(response); } catch (error) { @@ -376,7 +405,8 @@ export async function authentication(request, nextHandler) { // if we are rejecting the credentials (401, possibly rewritten to a login redirect); such a // response must never be stored by a shared cache and served to a different principal (#1565) const rejectedAuth = response?.status === 401 || wasUnauthorized; - const identityDependent = !!request.user || rejectedAuth; + // A downstream response produced under the untouched credential remains identity-dependent (#1565). + const identityDependent = !!request.user || rejectedAuth || !!getDeferredCredentialRejection(request); // with CORS enabled the response is origin-dependent — ACAO reflects the request Origin, and // its absence when no Origin was sent is origin-dependent too — so a shared cache must // partition on Origin either way (#1518) diff --git a/security/credentialRejection.ts b/security/credentialRejection.ts new file mode 100644 index 0000000000..acc3dfc5e4 --- /dev/null +++ b/security/credentialRejection.ts @@ -0,0 +1,22 @@ +import { ClientError } from '../utility/errors/hdbError.ts'; + +// Explicit provenance prevents internal faults with a 4xx status from being deferred as rejected credentials. +const CREDENTIAL_REJECTION = Symbol('harper.credentialRejection'); + +export function markCredentialRejection(error: E): E { + Object.defineProperty(error, CREDENTIAL_REJECTION, { + value: true, + enumerable: false, + configurable: true, + writable: false, + }); + return error; +} + +export function credentialRejectionError(message: string, statusCode: number): ClientError { + return markCredentialRejection(new ClientError(message, statusCode)); +} + +export function isCredentialRejection(error: unknown): boolean { + return (error as Record | null | undefined)?.[CREDENTIAL_REJECTION] === true; +} diff --git a/security/deferredAuthentication.ts b/security/deferredAuthentication.ts new file mode 100644 index 0000000000..10bb517174 --- /dev/null +++ b/security/deferredAuthentication.ts @@ -0,0 +1,71 @@ +import { ClientError } from '../utility/errors/hdbError.ts'; +import { serializeMessage, findBestSerializer } from '../server/serverHelpers/contentTypes.ts'; +import { Headers } from '../server/serverHelpers/Headers.ts'; + +export { isCredentialRejection, markCredentialRejection, credentialRejectionError } from './credentialRejection.ts'; + +const DEFERRED_CREDENTIAL_REJECTION = Symbol('harper.deferredCredentialRejection'); + +export type DeferredCredentialRejection = { + readonly status: number; + readonly message: string; + readonly strategy: string; +}; + +const CREDENTIAL_REJECTION_STATUS = 401; + +/** + * Records that this request presented a credential Harper rejected, without deciding the request. + * The caller leaves `request.user` unset and the inbound `Authorization` header untouched. + * + * The immutable, non-enumerable descriptor prevents downstream middleware from clearing it and + * keeps it out of request copies and serialization. + */ +export function deferCredentialRejection(request: any, error: { message?: string }, strategy: string): void { + if (getDeferredCredentialRejection(request)) return; + const deferred: DeferredCredentialRejection = Object.freeze({ + status: CREDENTIAL_REJECTION_STATUS, + message: error?.message ?? 'Unauthorized', + strategy, + }); + Object.defineProperty(request, DEFERRED_CREDENTIAL_REJECTION, { + value: deferred, + enumerable: false, + configurable: false, + writable: false, + }); +} + +export function getDeferredCredentialRejection(request: any): DeferredCredentialRejection | undefined { + return request?.[DEFERRED_CREDENTIAL_REJECTION]; +} + +/** + * The response an owning layer returns once it has established Harper owns the route: exactly the + * descriptor `security/auth.ts` returns in-line, so immediate and deferred rejection share a wire + * contract. + * + * Owner-specific error mapping must not run first. REST renders a thrown error as an RFC 9457 + * Problem Details document and GraphQL as `{errors:[…]}` rather than authentication's negotiated + * `{error: message}` response. `security/auth.ts` likewise leaves a settled rejection's status and + * challenge headers alone, so it stays byte-identical to the in-line 401 it replaced. + * + * Returns `undefined` when nothing was deferred, so a caller can `return settled ?? …` inline. + */ +export function settleDeferredCredentialRejection( + request: any +): { status: number; headers: Headers; body: string | Buffer } | undefined { + const deferred = getDeferredCredentialRejection(request); + if (!deferred) return undefined; + const contentType = (request?.headers ? findBestSerializer(request).type : undefined) ?? 'application/json'; + return { + status: deferred.status, + headers: new Headers({ 'Content-Type': contentType }), + body: serializeMessage({ error: deferred.message }, request) as string | Buffer, + }; +} + +export function assertNoDeferredCredentialRejection(request: any): void { + const deferred = getDeferredCredentialRejection(request); + if (deferred) throw new ClientError(deferred.message, deferred.status); +} diff --git a/security/tokenAuthentication.ts b/security/tokenAuthentication.ts index 56b7548618..926157a31d 100644 --- a/security/tokenAuthentication.ts +++ b/security/tokenAuthentication.ts @@ -10,7 +10,7 @@ import { SYSTEM_SCHEMA_NAME, SYSTEM_TABLE_NAMES, } from '../utility/hdbTerms.ts'; -import { ClientError, hdbErrors } from '../utility/errors/hdbError.ts'; +import { ClientError, ServerError, hdbErrors } from '../utility/errors/hdbError.ts'; const { HTTP_STATUS_CODES, AUTHENTICATION_ERROR_MSGS } = hdbErrors; import logger from '../utility/logging/harper_logger.ts'; import * as password from '../utility/password.ts'; @@ -23,6 +23,7 @@ import { markTokenAsWorkloadIdentity, } from './credentialProvenance.ts'; import { buildScopedTokenUser, syntheticRoleName } from './impersonation.ts'; +import { credentialRejectionError, isCredentialRejection } from './credentialRejection.ts'; import type { ImpersonatePayload } from '../server/operationsServer.ts'; import { expandOperationsPerms } from '../utility/operationPermissions.ts'; import { update } from '../dataLayer/insert.ts'; @@ -414,6 +415,7 @@ export async function validateLoginToken(token: string): Promise { async function validateToken(token: string, tokenType: string): Promise { try { const keys: JWTRSAKeys = await getJWTRSAKeys(); + assertUsableVerificationKey(keys.publicKey); // The OPERATION type also accepts scoped tokens, so the subject is checked after // verification rather than pinned in the verify options. const tokenVerified = jwt.verify( @@ -428,18 +430,18 @@ async function validateToken(token: string, tokenType: string): Promise { return buildUserFromScopedToken(tokenVerified); } if (tokenVerified.sub !== tokenType) { - throw new Error('Invalid token'); + throw credentialRejectionError(AUTHENTICATION_ERROR_MSGS.INVALID_TOKEN, HTTP_STATUS_CODES.UNAUTHORIZED); } // If a role is present, it means the token is not an operation token. The validation of // the token will happen in the respective function/component that uses the token. if (tokenVerified.role) { - throw new Error('Invalid token'); + throw credentialRejectionError(AUTHENTICATION_ERROR_MSGS.INVALID_TOKEN, HTTP_STATUS_CODES.UNAUTHORIZED); } const user: any = await findAndValidateUser(tokenVerified.username, undefined, false); if (tokenType === TOKEN_TYPE.REFRESH && !password.validate(user.refresh_token, token)) { - throw new Error('Invalid token'); + throw credentialRejectionError(AUTHENTICATION_ERROR_MSGS.INVALID_TOKEN, HTTP_STATUS_CODES.UNAUTHORIZED); } // Surfaced as `tokenOperations` rather than merged into role.permission.operations: that field @@ -455,10 +457,28 @@ async function validateToken(token: string, tokenType: string): Promise { } catch (err) { logger.warn(err); if (err?.name === 'TokenExpiredError') { - throw new ClientError(AUTHENTICATION_ERROR_MSGS.TOKEN_EXPIRED, HTTP_STATUS_CODES.FORBIDDEN); + throw credentialRejectionError(AUTHENTICATION_ERROR_MSGS.TOKEN_EXPIRED, HTTP_STATUS_CODES.FORBIDDEN); } + // Only positively classified token failures may cross the route-ownership boundary as rejections. + if (!isTokenRejection(err)) throw err; - throw new ClientError(AUTHENTICATION_ERROR_MSGS.INVALID_TOKEN, HTTP_STATUS_CODES.UNAUTHORIZED); + throw credentialRejectionError(AUTHENTICATION_ERROR_MSGS.INVALID_TOKEN, HTTP_STATUS_CODES.UNAUTHORIZED); + } +} + +const JWT_REJECTION_ERROR_NAMES = new Set(['JsonWebTokenError', 'NotBeforeError', 'TokenExpiredError']); +// jsonwebtoken reports unusable verification keys and bad tokens through the same error type. +const KEY_MATERIAL_FAULT = /secretOrPublicKey|asymmetric key|PEM routines|^error:/i; + +function isTokenRejection(err: any): boolean { + if (isCredentialRejection(err)) return true; + if (!JWT_REJECTION_ERROR_NAMES.has(err?.name)) return false; + return !KEY_MATERIAL_FAULT.test(String(err?.message ?? '')); +} + +function assertUsableVerificationKey(publicKey: unknown): void { + if (typeof publicKey !== 'string' || !publicKey.includes('-----BEGIN')) { + throw new ServerError(AUTHENTICATION_ERROR_MSGS.NO_ENCRYPTION_KEYS, HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR); } } @@ -470,7 +490,7 @@ async function validateToken(token: string, tokenType: string): Promise { function buildUserFromScopedToken(claims: JwtPayload): User { const embedded = (claims.role as { permission?: Record })?.permission; if (!embedded || typeof embedded !== 'object' || Array.isArray(embedded) || typeof claims.username !== 'string') { - throw new Error('Invalid token'); + throw credentialRejectionError(AUTHENTICATION_ERROR_MSGS.INVALID_TOKEN, HTTP_STATUS_CODES.UNAUTHORIZED); } const permission: Record = { ...embedded, super_user: false, cluster_user: false }; // Hashed from the server-side downgraded clone (before the _expandedOperations Set is attached), diff --git a/security/user.ts b/security/user.ts index 943cff4819..ae2d80058d 100644 --- a/security/user.ts +++ b/security/user.ts @@ -107,6 +107,7 @@ import { server } from '../server/Server.ts'; import * as terms from '../utility/hdbTerms.ts'; import { expandOperationsPerms } from '../utility/operationPermissions.ts'; import { activeSuperUserRemains } from './superUserGuard.ts'; +import { credentialRejectionError } from './credentialRejection.ts'; server.getUser = (username: string, password?: string | null): Promise => { return findAndValidateUser(username, password, password != null); @@ -420,11 +421,11 @@ async function findAndValidateUser(username: string, pw?: string | null, validat const userTmp = usersWithRolesMap.get(username); if (!userTmp) { if (!validatePassword) return { username }; - throw new ClientError(AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL, HTTP_STATUS_CODES.UNAUTHORIZED); + throw credentialRejectionError(AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL, HTTP_STATUS_CODES.UNAUTHORIZED); } if (userTmp && !userTmp.active) - throw new ClientError(AUTHENTICATION_ERROR_MSGS.USER_INACTIVE, HTTP_STATUS_CODES.UNAUTHORIZED); + throw credentialRejectionError(AUTHENTICATION_ERROR_MSGS.USER_INACTIVE, HTTP_STATUS_CODES.UNAUTHORIZED); const user: User = { active: userTmp.active, @@ -449,7 +450,7 @@ async function findAndValidateUser(username: string, pw?: string | null, validat // argon2id hash validation is async so await it if it is a promise if (typeof validated === 'object' && (validated as Promise)?.then) validated = await validated; if (validated === true) passwordHashCache.set(pw, userTmp.password); - else throw new ClientError(AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL, HTTP_STATUS_CODES.UNAUTHORIZED); + else throw credentialRejectionError(AUTHENTICATION_ERROR_MSGS.GENERIC_AUTH_FAIL, HTTP_STATUS_CODES.UNAUTHORIZED); } } return user; diff --git a/server/DESIGN.md b/server/DESIGN.md index 63e490f9c8..9dc439e3bb 100644 --- a/server/DESIGN.md +++ b/server/DESIGN.md @@ -131,7 +131,8 @@ Every entry is a top-level function or named const. Jump via go-to-symbol or `gr | `getPorts()` | Resolves listener options → list of `{port, secure}`. | | `httpServer()` | Main listener registration entry point. | | `getHTTPServer(port, secure, options)` | **The largest function in the file.** Creates/retrieves the underlying Node HTTP/HTTPS server. Wires `request`, `upgrade`, error handlers, TLS context, and the per-port middleware chain. | -| `makeCallbackChain()` | Builds the per-port handler chain via `middlewareChain.topoSort`. | +| `makeCallbackChain()` | Builds the per-port handler chain via `middlewareChain.topoSort`, and records its resolved order for `get_status`. | +| `buildChains()` | Stores a built chain, and rebuilds every other already-built chain of that kind when the registration is on the `'all'` pseudo-port. See "Middleware ordering" below. | | `unhandled()` | Terminal 404 handler. | | `onRequest()` | Thin alias of `httpServer({requestOnly: true})`. | | `onUpgrade()` / `upgradeListeners` (const) | Register HTTP upgrade listener; underlying list. | @@ -151,6 +152,19 @@ Components register listeners with optional `before: 'name'` / `after: 'name'` o The default WebSocket upgrade handler is registered automatically inside `onWebSocket()` the first time it runs for a given port. +**`port: 'all'` is a pseudo-port, and its chain is not the one that serves traffic.** Chain building +folds every `'all'` entry into each concrete port's chain, so `chains.all` exists but nothing +dispatches through it — `httpChain[port]`/`upgradeChains[port]`/`websocketChains[port]` are looked up +by the bound port on every request (Node, Bun, and uWS alike). A registration therefore has to +rebuild the chains it affects, not just its own key: `http.ts → buildChains()` rebuilds every +already-built chain of that kind whenever the registration is on `'all'`. Without that, an entry +registered on `'all'` after the concrete port's chain was built — the shape an application catch-all +mounted `after: 'rest'` takes, since `rest` registers first — updates only `chains.all` and never +reaches a request (#2418). Rebuilding is a pure function of the listener list and the port, so the +extra passes can only reproduce a port's order or extend it. `buildChains()` also writes the +`get_status` chain description in the same pass, which is what keeps that report from ever +describing an order a port isn't running (#1573). + ### Application mounts (`host` / `urlPath` in the root config) An operator mounts an application by putting `host`/`urlPath` on its entry in the **root** config; `components/scopeMount.ts` models it and the loader threads it into every `Scope` for that application (both load paths — the root-config `package` recursion and the components-root directory scan). @@ -194,12 +208,127 @@ mirror because it only widens what an allowlist may _name_; enforcement stays on `REST.ts → http(request, nextHandler)` is the chief integration point: it takes a `Request`, asks the `Resources` registry for a match, builds a `RequestTarget`, and dispatches into the Resource class's static method. Cache headers are translated to `request.expiresAt` / `onlyIfCached` / `noCache` flags within the same function. +### Deferred credential rejection (#2418) + +`authentication` runs before route matching, so when it meets an `Authorization` header it cannot +resolve it does not yet know whether Harper or an application owns the URL. Rejecting there forces +an application to choose between two things it needs: Harper owning its own routes (status, REST +resources) and its own routes receiving their own credential scheme untouched. + +So a rejection is recorded rather than answered: + +- **Valid Harper credentials** authenticate normally and populate `request.user`. Unchanged. +- **No credentials** continue anonymously. Unchanged. +- **A syntactically valid credential Harper does not recognize** leaves `request.user` unset, leaves + the inbound `Authorization` header byte-for-byte intact, and records request-local state through + `security/deferredAuthentication.ts`. The state lives behind a module-private `Symbol`. Its + descriptor and value are immutable and non-enumerable, so downstream middleware cannot clear it + and it does not leak through request copies or serialization. +- **An internal authentication fault** — unreadable or malformed JWT key material, a storage failure, + an unexpected error type — is never deferred, and fails closed with the in-line 401. +- **The operations API** never defers: `request.isOperationsServer` short-circuits to the in-line + 401, because every operations route is Harper-owned and there is nothing to defer to. + +**Rejection provenance is asserted, never inferred.** `security/credentialRejection.ts` holds a +module-private `Symbol` tag; only the code that actually concludes "this credential is unacceptable" +sets it, and `isCredentialRejection()` reads nothing else. Status ranges cannot carry that meaning: +`findAndValidateUser()` lazily loads the user cache, whose system-table searches raise a +default-status-400 `ClientError` when `system.hdb_role`/`system.hdb_user` is unavailable, so a 4xx +test would classify a storage outage as an unknown credential and hand it to application +authorization. The tag is set at exactly these points: + +| Tagged rejection | Where | +| ------------------------------------------------------------------ | ------------------------------------------ | +| unknown user, inactive user, bad password | `security/user.ts → findAndValidateUser()` | +| JWT syntax, signature, expiry, not-before, subject/claim rejection | `tokenAuthentication.ts → validateToken()` | +| refresh-token hash mismatch, malformed scoped-token claims | `tokenAuthentication.ts` | +| an `Authorization` scheme Harper does not implement | `security/auth.ts` | + +`validateToken()` separates the two in the same catch: `jsonwebtoken` reports unusable key material +through the very same `JsonWebTokenError` type it uses for a forged token, so the public key is +validated as key material before `jwt.verify()` runs and a residual key-material message is treated +as a fault. An untagged error propagates unmasked. + +The Bearer path's refresh-token probe follows the same rule. When operation-token validation says +`invalid token`, authentication retries the credential as a refresh token; a fault raised by that +retry propagates, and only an ordinary _tagged_ refresh rejection restores the original +operation-token rejection for deferral. An in-line fail-closed response logs the original fault +server-side and returns the same generic authentication failure a rejected credential gets, so +internal detail never reaches an unauthenticated client. + +Any layer that establishes Harper owns the route then settles the deferred state before doing work: + +| Layer | Where | +| --------------------------------------- | ------------------------------------------------------------------ | +| `REST.ts → http()` | after `resources.getMatch` succeeds (and for the OpenAPI document) | +| `REST.ts` WebSocket handler | after `resources.getMatch(url, 'ws')` succeeds | +| `graphqlQuerying.ts` | after the `/graphql` prefix match, ahead of its error mapping | +| `static.ts` | after a static file entry matches | +| `mqtt.ts` WebSocket handler | once the pending HTTP chain settles, before the first packet | +| `components/mcp/adapters/harperHttp.ts` | after the WebSocket hand-off, before the body is read | + +**Every Harper-owned handler registered `after: 'authentication'` owes this settlement**, because +declining to call `nextHandler` is precisely the moment ownership is settled. A handler that skips +it serves an unrecognized credential as anonymous — the MCP application mount did exactly that, and +returned 200 for an `initialize` the base revision answered with 401. + +Settlement goes through `settleDeferredCredentialRejection()`, which returns the response descriptor +`security/auth.ts` used to return in line: status 401 and `serializeMessage({error: message}, request)` +in the request's negotiated content type. That matters because an owner's own error mapping is not +that contract — REST renders a thrown error as an RFC 9457 Problem Details document and GraphQL as +`{errors:[{message}]}` — and a rejected credential never reached either before deferral existed. +`assertNoDeferredCredentialRejection()` is the throwing form, for a WebSocket upgrade that has no +descriptor to return. A WebSocket owner cannot read the state synchronously: `server/http.ts` starts +`httpChain[port](request)` and invokes the WebSocket chain with the still-pending completion, so +authentication has not yet classified the credential. `mqtt.ts` therefore settles on that promise — +the same one the session's principal resolves from — and closes the socket from its rejection, while +its frame handlers still attach synchronously. The deferred status is pinned to 401 regardless of the underlying error's own +status, so a 403 `token expired` reads exactly as it did before. A Harper-owned route therefore behaves identically to the pre-deferral +build, protected or public: an unknown credential can never buy access an anonymous caller would +have received, and can never reach an application catch-all. Only a URL that reached +`nextHandler` — one no Harper route owns — carries the original header onward. + +The contract is route-ownership-based, not path-based. There is no exemption list, no carrier +header, no credential rename, and no pre-auth stripping shim. + +**Authentication does not re-decorate a 401 it did not raise.** `security/auth.ts` post-processes any +401 coming back up the chain — overwriting `WWW-Authenticate` with `Basic`, or rewriting the status to +a 302 at `resources.loginPath` for a browser. The in-line rejection deferral replaced returned before +that code, so a settled rejection must skip it to stay wire-identical; and a 401 an application +catch-all raised for its own scheme (a WooCommerce or Bearer challenge) is that application's to make. +Both cases are keyed on the deferred state, so a request that deferred nothing keeps the existing +behavior exactly. The #1565 identity floor still applies either way — it is stamped in +`applyResponseHeaders`, not in the challenge rewriting. + +**The identity cache floor survives the legacy Fastify fallbacks.** A response produced under a +deferred credential is credential-dependent (#1565), so `authentication` stamps +`Cache-Control: private, no-cache` and `Vary: Authorization, Cookie` on it. Before deferral an +unrecognized credential could not reach a fallback at all, so this was unreachable. All three adapters +now reconcile through one policy, `Headers.ts → mergeChainHeadersIntoFallback()`: Fastify wins every +single-valued header it set, `Vary` is unioned, and the private scope is re-applied unless the final +response explicitly opts into shared caching (`public`/`s-maxage`). + +`Set-Cookie` is the deliberate exception to Fastify-wins. It is a list-valued field, so the chain's +cookies are appended beside Fastify's and de-duplicated by exact value, never by cookie name. A cookie +is identified by name _plus_ `Domain` _plus_ `Path` (RFC 6265 §5.3), so collapsing by name drops +legitimately distinct cookies — a same-name pair scoped to `/` and `/wp-admin`, or a `Max-Age=0` +deletion paired with a set. Exact value is also what makes Node's `writeHead` re-merge idempotent, +because that path sees the chain's own cookie already on the response. Two cookies that do share a +full identity both reach the client, chain last, and the user agent resolves them last-wins. + +Bun and uWS rebuild their headers from Fastify's reply and merge once. Node hands Fastify the same +`ServerResponse` the chain's headers were copied onto, so copying is not enough — a route calling +`reply.header('Cache-Control', …)` replaces the floor outright and can make a credential-dependent +response shared-cacheable. `bridgeChainHeadersToNodeResponse()` therefore runs the same merge from a +`writeHead` interception, the last point the header set is still mutable and the one Node also routes +implicit headers through. + ### Response Cache-Control / Vary policy (#1518, #1565) Three tiers, applied in two places: 1. **App/resource explicit** — a `Cache-Control` set by the resource (or `@table(cacheControl: "...")` for anonymous reads, emitted in `REST.ts → http()`) always wins. The declaration is required: anonymous readability alone never emits shared-cache headers, because a request-attribute-gated `allowRead` (IP, headers) would make inferred `public` unsound. -2. **Identity floor** — `security/auth.ts → applyResponseHeaders` stamps `Cache-Control: private, no-cache` + `Vary: Authorization` (+ `Cookie` when sessions are on) on any response where a principal was resolved or credentials were rejected (401), _unless_ the app opted into shared caching with `public`/`s-maxage` (the RFC 9111 opt-in). +2. **Identity floor** — `security/auth.ts → applyResponseHeaders` stamps `Cache-Control: private, no-cache` + `Vary: Authorization` (+ `Cookie` when sessions are on) on any response where a principal was resolved, credentials were rejected (401), or a credential rejection was deferred (#2418 — the application answered using the header Harper passed through, so the response is credential-dependent at a plain 200), _unless_ the app opted into shared caching with `public`/`s-maxage` (the RFC 9111 opt-in). 3. **CORS partitioning** — when CORS is enabled, every response gets `Vary: Origin` (the ACAO header is reflected per-origin, and its absence on no-Origin requests is origin-dependent too). The `@table(cacheControl:)` value is persisted on the primary-key attribute (like `expiration`), so all threads and future boots see it; `resources/databases.ts → table()` treats `null` as "schema explicitly has none" (clears on reload) and `undefined` as "caller is not schema-defining" (no clobber from `add_attribute`/cluster schema events). diff --git a/server/REST.ts b/server/REST.ts index e9b76af516..d86be3a777 100644 --- a/server/REST.ts +++ b/server/REST.ts @@ -15,6 +15,10 @@ import { CONFIG_PARAMS } from '../utility/hdbTerms.ts'; import { ASIDE_STAGING_DIR } from '../components/Application.ts'; import { COMPONENT_PREPARATION_LOCK_DIR } from '../components/componentPreparationLock.ts'; import { restartNeeded } from '../components/requestRestart.ts'; +import { + assertNoDeferredCredentialRejection, + settleDeferredCredentialRejection, +} from '../security/deferredAuthentication.ts'; import { Request } from '../server/serverHelpers/Request.ts'; import { RequestTarget } from '../resources/RequestTarget'; @@ -222,6 +226,8 @@ async function http(request: Request, nextHandler, resources: Resources, httpOpt } } } + const settledCredentialRejection = settleDeferredCredentialRejection(request); + if (settledCredentialRejection) return settledCredentialRejection; if ((resource as any)?.isCaching) { const cacheControl = headersObject['cache-control']; if (cacheControl) { @@ -553,6 +559,7 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope) // TODO: Ideally we would like to have a 404 response before upgrading to WebSocket protocol, probably return ws.close(1011, `No resource was found to handle ${request.pathname}`); } else { + assertNoDeferredCredentialRejection(request); request.handlerPath = entry.path; recordAction( (action) => ({ diff --git a/server/graphqlQuerying.ts b/server/graphqlQuerying.ts index b4fe105299..3af0de514b 100644 --- a/server/graphqlQuerying.ts +++ b/server/graphqlQuerying.ts @@ -3,6 +3,7 @@ import type { RequestParams } from 'graphql-http'; import { getDeserializer } from './serverHelpers/contentTypes.ts'; import { resources } from '../resources/Resources.ts'; import logger from '../utility/logging/harper_logger.ts'; +import { settleDeferredCredentialRejection } from '../security/deferredAuthentication.ts'; // This code makes heavy use of the word "node" to refer to a node in the GraphQL AST. @@ -580,6 +581,9 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope) return nextLayer(request); } + const settledCredentialRejection = settleDeferredCredentialRejection(request); + if (settledCredentialRejection) return settledCredentialRejection; + try { // Await the `graphqlHandler` call here so that errors are caught. return await graphqlQueryingHandler(request as any); diff --git a/server/http.ts b/server/http.ts index 0a212cff16..f73807af50 100644 --- a/server/http.ts +++ b/server/http.ts @@ -18,7 +18,13 @@ import { createServer as createSecureServerHttp1 } from 'node:https'; import { createServer, IncomingMessage, validateHeaderName, validateHeaderValue } from 'node:http'; import { createServer as createNetServer } from 'node:net'; import { Request, BunRequest, UwsRequest, isBun } from './serverHelpers/Request.ts'; -import { appendHeader, Headers, toWriteHeadHeaders } from './serverHelpers/Headers.ts'; +import { + appendHeader, + bridgeChainHeadersToNodeResponse, + Headers, + mergeChainHeadersIntoFallback, + toWriteHeadHeaders, +} from './serverHelpers/Headers.ts'; import { decodeProxyHeader, applyProxyHeader, @@ -34,7 +40,7 @@ import { server, type ServerOptions, type HttpOptions, type UpgradeOptions, Upgr import { setPortServerMap, SERVERS, socketOptionDefaults } from './serverRegistry.ts'; import { getComponentName } from '../components/componentLoader.ts'; import { throttle } from './throttle.ts'; -import { makeCallbackChain as buildCallbackChain, describeChains } from './middlewareChain.ts'; +import { makeCallbackChain as buildCallbackChain, describeChains, type HttpEntry } from './middlewareChain.ts'; import { WebSocketServer } from 'ws'; const { errorToString, errorForLog } = harperLogger; @@ -490,7 +496,7 @@ export function httpServer(listener, options) { httpResponders[options?.runFirst ? 'unshift' : 'push'](entry); } else if (isBun) { // On Bun, store non-function listeners (e.g. Fastify's http.Server) for fallback delegation - fallbackServers[port] = listener; + registerFallbackServer(port, listener); } else if ((httpServers[port] as any)?.uws) { // uWS HTTP path (#914, HARPER_UWS_HTTP): the port is backed by uWebSockets.js, not a Node // http server, so a raw non-function listener (e.g. Fastify's http.Server via @@ -499,12 +505,12 @@ export function httpServer(listener, options) { // port. Divert it to the fallback map like the Bun path; makeUwsHandler delegates unhandled // requests to it via inject(). The { uws: true } marker is guaranteed present here: the // getServer(port) call above (same loop iteration) sets it before this branch runs. - fallbackServers[port] = listener; + registerFallbackServer(port, listener); } else { listener.isSecure = secure; registerServer(listener, port, false); } - httpChain[port] = makeCallbackChain(httpResponders, port); + buildChains(httpChain, httpResponders, port); } return servers; @@ -679,9 +685,7 @@ function getHTTPServer(port: number, secure: boolean, options: ServerOptions) { // This means the HDB stack didn't handle the request, and we can then cascade the request // to the server-level handler, forming the bridge to the slower legacy fastify framework that expects // to interact with a node HTTP server object. - for (const headerPair of response.headers || []) { - nodeResponse.setHeader(headerPair[0], headerPair[1]); - } + bridgeChainHeadersToNodeResponse(response.headers, nodeResponse); nodeRequest.baseRequest = request; nodeResponse.baseResponse = response; return httpServers[port].emit('unhandled', nodeRequest, nodeResponse); @@ -984,7 +988,7 @@ function getHTTPServer(port: number, secure: boolean, options: ServerOptions) { * and a Fastify fallback is registered for the port, it delegates via inject() (see injectToFastify), * mirroring the Bun path — so legacy Fastify routes work behind uWS too. */ -function makeUwsHandler(port: number | string, isOperationsServer: boolean, requestQueueLimit?: number) { +export function makeUwsHandler(port: number | string, isOperationsServer: boolean, requestQueueLimit?: number) { // Build a fresh response descriptor rather than mutating what the chain returned: a handler may // return a WHATWG `Response` (read-only `status`/`body` accessors), which the Bun path also never // mutates. `headers` is normalized in place the same way the Bun path does. @@ -1020,6 +1024,7 @@ function makeUwsHandler(port: number | string, isOperationsServer: boolean, requ if (Array.isArray(v)) respHeaders.set(k, k.toLowerCase() === 'set-cookie' ? v : v.join(', ')); else respHeaders.set(k, String(v)); } + mergeChainHeadersIntoFallback(headers, respHeaders); if (universalHeaders.length > 0) applyUniversalHeaders(respHeaders); logHttpRequest(request, injectResult.statusCode, requestId, performance.now() - startTime); const responseStream = injectResult.stream(); @@ -1043,6 +1048,7 @@ function makeUwsHandler(port: number | string, isOperationsServer: boolean, requ } logHttpRequest(request, 404, requestId, performance.now() - startTime); const notFoundHeaders = new Headers({ 'content-type': 'text/plain' }); + mergeChainHeadersIntoFallback(headers, notFoundHeaders); if (universalHeaders.length > 0) applyUniversalHeaders(notFoundHeaders); return { status: 404, headers: notFoundHeaders, body: 'Not found\n' }; } @@ -1217,10 +1223,11 @@ function getBunHTTPServer(port: number, secure: boolean, options: ServerOptions) // Delegate to the fallback server (e.g. Fastify) via node:http compatibility. // We create a Node-compatible IncomingMessage/ServerResponse and emit 'request' // on the fallback server, then capture the response. - return await bunDelegateToNodeServer(fallbackServer, webRequest, request); + return await bunDelegateToNodeServer(fallbackServer, webRequest, request, response.headers); } logHttpRequest(request, 404, requestId, performance.now() - startTime); const notFoundHeaders = new globalThis.Headers(); + mergeChainHeadersIntoFallback(response.headers, notFoundHeaders); if (universalHeaders.length > 0) applyUniversalHeaders(notFoundHeaders); return new Response('Not found\n', { status: 404, headers: notFoundHeaders }); } @@ -1404,6 +1411,10 @@ let fastifyInstances: Record = {}; export function registerFastifyInstance(port: string | number, instance: any) { fastifyInstances[port] = instance; } + +export function registerFallbackServer(port: string | number, listener: any) { + fallbackServers[port] = listener; +} const INTERNAL_USER_HEADER = 'x-harper-internal-pre-auth-user'; /** @@ -1434,10 +1445,11 @@ function injectToFastify( return fastify.inject({ method: req.method, url: req.url, headers, payload: req.body, payloadAsStream: true }); } -async function bunDelegateToNodeServer( +export async function bunDelegateToNodeServer( nodeServer: any, webRequest: globalThis.Request, - bunRequest?: any + bunRequest?: any, + chainHeaders?: any ): Promise { // Check if there's a Fastify instance registered for this port (preferred path) for (const port in fallbackServers) { @@ -1458,13 +1470,19 @@ async function bunDelegateToNodeServer( }); const webHeaders = new globalThis.Headers(); for (const [k, v] of Object.entries(injectResult.headers)) { - if (v != null) webHeaders.set(k, Array.isArray(v) ? v.join(', ') : String(v)); + if (v == null) continue; + if (Array.isArray(v)) { + if (k.toLowerCase() === 'set-cookie') { + for (const single of v) webHeaders.append(k, String(single)); + } else webHeaders.set(k, v.join(', ')); + } else webHeaders.set(k, String(v)); } // Propagate Connection: close so Bun closes the TCP connection after this response, // preventing stale keep-alive sockets from causing silent hangs on subsequent requests. if (webRequest.headers.get('connection')?.toLowerCase() === 'close') { webHeaders.set('connection', 'close'); } + mergeChainHeadersIntoFallback(chainHeaders, webHeaders); if (universalHeaders.length > 0) applyUniversalHeaders(webHeaders); const responseStream = injectResult.stream(); // Event-stream responses (MCP SSE) must reach the client incrementally — return @@ -1499,6 +1517,7 @@ async function bunDelegateToNodeServer( } // No Fastify instance found — return 404 const notFoundHeaders = new globalThis.Headers(); + mergeChainHeadersIntoFallback(chainHeaders, notFoundHeaders); if (universalHeaders.length > 0) applyUniversalHeaders(notFoundHeaders); return new Response('Not found\n', { status: 404, headers: notFoundHeaders }); } @@ -1507,14 +1526,39 @@ type SerializedRoute = { host?: string; urlPath?: string; order: string[] }; // Resolved order captured at chain-build time, keyed identically to httpChain/upgradeChains/ // websocketChains (kind → port → routes). Reporting the stored build-time order rather than // recomputing from current responders guarantees get_status matches the callback chain actually -// serving that port — including cases where a late `port: 'all'` registration rebuilds only the -// 'all' chain and leaves a concrete port's chain (and this description) unchanged (#1573). +// serving that port: buildChains() writes both in the same pass, so a description can never +// describe an order the port isn't running (#1573). const resolvedChainDescriptions: Record> = { http: {}, upgrade: {}, websocket: {}, }; +// Preserve the original port type because route selection uses strict equality. +const builtChainPorts: Record> = { + http: new Map(), + upgrade: new Map(), + websocket: new Map(), +}; + +// A late registration on 'all' must rebuild every already-bound concrete port (#2418). +function buildChains( + chains: Record, + listeners: HttpEntry[], + port: number | string, + requestArgIndex: number = 0, + kind: string = 'http' +) { + builtChainPorts[kind].set(String(port), port); + if (port !== 'all') { + chains[port] = makeCallbackChain(listeners, port, requestArgIndex, kind); + return; + } + for (const builtPort of builtChainPorts[kind].values()) { + chains[builtPort] = makeCallbackChain(listeners, builtPort, requestArgIndex, kind); + } +} + function makeCallbackChain( responders: typeof httpResponders, portNum: number | string, @@ -1640,7 +1684,7 @@ function onUpgrade(listener: UpgradeListener, options: UpgradeOptions) { host: options?.host || undefined, }; upgradeListeners[options?.runFirst ? 'unshift' : 'push'](entry); - upgradeChains[port] = makeCallbackChain(upgradeListeners, port, 0, 'upgrade'); + buildChains(upgradeChains, upgradeListeners, port, 0, 'upgrade'); } } @@ -1779,10 +1823,10 @@ function onWebSocket(listener: (ws: WebSocket) => void, options: OnWebSocketOpti host: options?.host || undefined, }; websocketListeners[options?.runFirst ? 'unshift' : 'push'](wsEntry); - websocketChains[port] = makeCallbackChain(websocketListeners, port, 1, 'websocket'); + buildChains(websocketChains, websocketListeners, port, 1, 'websocket'); // mqtt doesn't invoke the http handler so this needs to be here to load up the http chains. - httpChain[port] = makeCallbackChain(httpResponders, port); + buildChains(httpChain, httpResponders, port); } return servers; diff --git a/server/mqtt.ts b/server/mqtt.ts index 682df230a0..5947b26415 100644 --- a/server/mqtt.ts +++ b/server/mqtt.ts @@ -19,6 +19,13 @@ import { forComponent as loggerForComponent } from '../utility/logging/harper_lo import { EventEmitter } from 'events'; import { verifyCertificate } from '../security/certificateVerification/index.ts'; import { registerShutdownDrain } from '../components/shutdownDrain.ts'; +import { + assertNoDeferredCredentialRejection, + getDeferredCredentialRejection, +} from '../security/deferredAuthentication.ts'; + +/** RFC 6455 private-use close code Harper already maps HTTP 401 to (see server/REST.ts). */ +const WEBSOCKET_UNAUTHORIZED_CLOSE_CODE = 3000; const authEventLog = loggerWithTag('auth-event'); const mqttLog = loggerForComponent('mqtt'); @@ -68,13 +75,29 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope) emitEvent('connection', ws); mqttLog.debug?.('Received WebSocket connection for MQTT from', ws._socket.remoteAddress); + // Both WebSocket entry points invoke this listener synchronously with the HTTP chain still + // pending (server/http.ts), so authentication has not recorded a credential rejection yet. + // It settles on the same promise the session principal comes from, which onSocket awaits + // before it processes any packet — the handlers below still attach synchronously, so no + // frame that arrives in the meantime is dropped. + const authenticated = Promise.resolve(chainCompletion).then(() => { + assertNoDeferredCredentialRejection(request); + return request?.user; + }); + authenticated.catch((error) => { + mqttLog.info?.('Closing MQTT WebSocket connection, authentication was rejected', error); + ws.close( + WEBSOCKET_UNAUTHORIZED_CLOSE_CODE, + getDeferredCredentialRejection(request)?.message ?? 'Unauthorized' + ); + }); const { onMessage, onClose } = onSocket( ws, (message) => { ws.send(message); }, request, - Promise.resolve(chainCompletion).then(() => request?.user), + authenticated, mqttSettings ); ws.on('message', onMessage); diff --git a/server/serverHelpers/Headers.ts b/server/serverHelpers/Headers.ts index a7ee3a20d5..62d295ec47 100644 --- a/server/serverHelpers/Headers.ts +++ b/server/serverHelpers/Headers.ts @@ -159,3 +159,134 @@ export function toWriteHeadHeaders(headers: any): any { } return result; } + +// RFC 9111 cache-scope directives; boundaries on both sides so a token like `public-foo` doesn't match. +export const SHARED_CACHE_OPTIN = /(^|[,\s])(public|s-maxage)($|[\s,;=])/i; +export const PRIVATE_SCOPE = /(^|[,\s])(private|no-store)($|[\s,;=])/i; + +function headerValueString(value: unknown): string { + if (value == null) return ''; + return Array.isArray(value) ? value.join(', ') : String(value); +} + +/** + * Folds the middleware chain's response headers into the headers a fallback server produced for the + * same request. + * + * Bun and uWS construct a new response from legacy Fastify when the chain declines a request, so the + * chain's credential-dependent cache policy must be merged explicitly. Fastify wins headers it set, + * `Vary` is unioned, and private cache scope is re-applied unless the final response explicitly opts + * into RFC 9111 shared caching with `public` or `s-maxage`. + */ +export function mergeChainHeadersIntoFallback< + T extends { + get(name: string): any; + set(name: string, value: any): any; + has(name: string): boolean; + append?(name: string, value: any): any; + }, +>(chainHeaders: any, finalHeaders: T): T { + if (!chainHeaders?.[Symbol.iterator]) return finalHeaders; + const chainVary = headerValueString(chainHeaders.get('Vary')); + const chainCacheControl = headerValueString(chainHeaders.get('Cache-Control')); + for (const [name, value] of chainHeaders) { + const lowerName = String(name).toLowerCase(); + if (lowerName === 'vary' || lowerName === 'cache-control') continue; + if (finalHeaders.has(name)) { + if (lowerName === 'set-cookie') { + const existing = (finalHeaders as any).getSetCookie?.() ?? finalHeaders.get(name); + const existingValues = new Set((Array.isArray(existing) ? existing : [existing]).map(String)); + const values = Array.isArray(value) ? value : [value]; + for (const single of values) { + if (!existingValues.has(String(single))) appendHeader(finalHeaders, name, single, false); + } + } + continue; + } + if (Array.isArray(value)) { + for (const single of value) appendHeader(finalHeaders, name, single, lowerName !== 'set-cookie'); + } else finalHeaders.set(name, value); + } + for (const token of chainVary.split(',')) { + const trimmed = token.trim(); + if (trimmed) addVaryHeader(finalHeaders as any, trimmed); + } + if (chainCacheControl) { + const finalCacheControl = headerValueString(finalHeaders.get('Cache-Control')); + if (!finalCacheControl) finalHeaders.set('Cache-Control', chainCacheControl); + else if ( + PRIVATE_SCOPE.test(chainCacheControl) && + !PRIVATE_SCOPE.test(finalCacheControl) && + !SHARED_CACHE_OPTIN.test(finalCacheControl) + ) + finalHeaders.set('Cache-Control', finalCacheControl + ', private'); + } + return finalHeaders; +} + +function nodeResponseHeaders(nodeResponse: any) { + return { + get: (name: string) => nodeResponse.getHeader(name), + set: (name: string, value: any) => nodeResponse.setHeader(name, value), + has: (name: string) => nodeResponse.hasHeader(name), + append: (name: string, value: any, commaDelimited?: boolean) => { + const existing = nodeResponse.getHeader(name); + if (existing == null) return nodeResponse.setHeader(name, value); + if (commaDelimited) + return nodeResponse.setHeader(name, (Array.isArray(existing) ? existing.join(', ') : existing) + ', ' + value); + return nodeResponse.setHeader(name, Array.isArray(existing) ? [...existing, value] : [existing, value]); + }, + }; +} + +function applyWriteHeadHeaders(nodeResponse: any, headers: any): void { + const suppliedHeaders = new Map(); + const addHeader = (name: string, value: any) => { + const key = String(name).toLowerCase(); + const supplied = suppliedHeaders.get(key); + if (!supplied) { + suppliedHeaders.set(key, { name, value }); + return; + } + const values = Array.isArray(supplied.value) ? supplied.value : [supplied.value]; + supplied.value = Array.isArray(value) ? values.concat(value) : values.concat([value]); + }; + if (Array.isArray(headers)) { + if (Array.isArray(headers[0])) { + for (const [name, value] of headers) addHeader(name, value); + } else { + for (let i = 0; i + 1 < headers.length; i += 2) addHeader(headers[i], headers[i + 1]); + } + } else { + for (const name of Object.keys(headers)) { + const value = headers[name]; + if (value != null) addHeader(name, value); + } + } + for (const { name, value } of suppliedHeaders.values()) nodeResponse.setHeader(name, value); +} + +/** + * Node's counterpart to the Bun and uWS fallback bridges: the chain's headers go onto the + * `ServerResponse` before legacy Fastify runs, so a Fastify route that sets `Cache-Control` or `Vary` + * replaces them outright and can make a credential-dependent response shared-cacheable (#1565). + * Reconciliation therefore runs at `writeHead` — the last point the header set is still mutable, and + * the one Node also routes implicit headers through — using the same policy as the other two bridges. + */ +export function bridgeChainHeadersToNodeResponse(chainHeaders: any, nodeResponse: any): void { + if (!chainHeaders?.[Symbol.iterator]) return; + for (const [name, value] of chainHeaders) nodeResponse.setHeader(name, value); + const originalWriteHead = nodeResponse.writeHead; + nodeResponse.writeHead = function (statusCode: number, statusMessage?: any, headers?: any) { + if (this.headersSent) return originalWriteHead.apply(this, arguments as any); + if (statusMessage != null && typeof statusMessage !== 'string') { + headers = statusMessage; + statusMessage = undefined; + } + if (headers) applyWriteHeadHeaders(this, headers); + mergeChainHeadersIntoFallback(chainHeaders, nodeResponseHeaders(this)); + return statusMessage === undefined + ? originalWriteHead.call(this, statusCode) + : originalWriteHead.call(this, statusCode, statusMessage); + }; +} diff --git a/server/static.ts b/server/static.ts index 80e7bf3ed0..e49e19065c 100644 --- a/server/static.ts +++ b/server/static.ts @@ -5,6 +5,7 @@ import { resolveBaseURLPath } from '../components/resolveBaseURLPath.ts'; import { convertToMS } from '../utility/common_utils.ts'; import { isMatch } from 'micromatch'; import send from 'send'; +import { settleDeferredCredentialRejection } from '../security/deferredAuthentication.ts'; /** * The static plugin handles serving static files from the respective application directory. @@ -337,40 +338,30 @@ export function handleApplication(scope: Scope) { // Retrieve index entry staticFile = indexEntries.get(req.pathname); - // The router strips both '/assets' and '/assets/' down to '/', so the mount root - // must be disambiguated via the unstripped pathname (exposed by stripPrefix): - // redirect the no-slash form so relative links on the index page resolve under - // the mount (#1583). Query string is preserved across both redirects; compute it - // lazily inside each branch so the common (non-redirect) index serve stays allocation-free. - // Gated on the EXTERNAL base path, not the plugin-local one: a root-level static - // plugin (baseURLPath === '/') still needs this redirect when the application - // itself carries a host/urlPath mount, since the client-visible mount root is then - // externalBaseURLPath, not '/' (review finding). - if (staticFile && req.pathname === '/' && externalBaseURLPath !== '/') { - const originalPathname: string | undefined = (req as any).originalPathname; - if (originalPathname && !originalPathname.endsWith('/')) { - const queryIndex = (req.url as string).indexOf('?'); - const query = queryIndex === -1 ? '' : (req.url as string).slice(queryIndex); - return { - status: 301, - headers: { - Location: externalBaseURLPath + query, - }, - }; - } - } + // Prefix stripping makes `originalPathname` necessary to distinguish mounted roots (#1583). + const originalPathname: string | undefined = (req as any).originalPathname; + const redirectsMountRoot = !!( + staticFile && + req.pathname === '/' && + externalBaseURLPath !== '/' && + originalPathname && + !originalPathname.endsWith('/') + ); - // If `null`, redirect to trailing slash. req.pathname arrives with the mount - // prefix stripped, so rebuild the external path for the Location header (#1583) - if (staticFile === null) { - const externalPath = - externalBaseURLPath === '/' ? req.pathname : externalBaseURLPath.slice(0, -1) + req.pathname; + if (redirectsMountRoot || staticFile === null) { + const settledCredentialRejection = settleDeferredCredentialRejection(req); + if (settledCredentialRejection) return settledCredentialRejection; const queryIndex = (req.url as string).indexOf('?'); const query = queryIndex === -1 ? '' : (req.url as string).slice(queryIndex); + const location = redirectsMountRoot + ? externalBaseURLPath + query + : (externalBaseURLPath === '/' ? req.pathname : externalBaseURLPath.slice(0, -1) + req.pathname) + + '/' + + query; return { status: 301, headers: { - Location: externalPath + '/' + query, + Location: location, }, }; } @@ -393,6 +384,8 @@ export function handleApplication(scope: Scope) { // If an entry matched, serve it if (staticFile) { + const settledCredentialRejection = settleDeferredCredentialRejection(req); + if (settledCredentialRejection) return settledCredentialRejection; // The benefit to using `send` is that it handles a lot of edge cases and headers for us. return { handlesHeaders: true, @@ -405,7 +398,8 @@ export function handleApplication(scope: Scope) { return next(req); } - // Otherwise, handle not found + const settledCredentialRejection = settleDeferredCredentialRejection(req); + if (settledCredentialRejection) return settledCredentialRejection; const notFound = scope.options.get(['notFound']); diff --git a/unitTests/components/mcp/adapters/harperHttp.test.js b/unitTests/components/mcp/adapters/harperHttp.test.js index c80fc25c57..7c0add61a6 100644 --- a/unitTests/components/mcp/adapters/harperHttp.test.js +++ b/unitTests/components/mcp/adapters/harperHttp.test.js @@ -2,6 +2,7 @@ const assert = require('node:assert'); const { Readable } = require('node:stream'); const { EventEmitter } = require('node:events'); const { createHarperHttpHandler } = require('#src/components/mcp/adapters/harperHttp'); +const { credentialRejectionError, deferCredentialRejection } = require('#src/security/deferredAuthentication'); const { _setSessionTableForTest, loadSession } = require('#src/components/mcp/session'); const { Headers } = require('#src/server/serverHelpers/Headers'); @@ -98,6 +99,72 @@ describe('mcp/adapters/harperHttp', () => { assert.equal(parsed.result.protocolVersion, '2025-06-18'); }); + describe('deferred credential rejection (#2418)', () => { + function deferredRequest(overrides = {}) { + const request = { + method: 'POST', + headers: makeHeaders({ 'content-type': 'application/json' }), + body: bodyStream( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } }) + ), + ...overrides, + }; + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + return request; + } + + it('answers 401 instead of creating an anonymous session', async () => { + const handler = createHarperHttpHandler('application'); + + const result = await handler(deferredRequest(), next); + + assert.equal(result.status, 401); + assert.deepEqual(JSON.parse(result.body.toString()), { error: 'Login failed' }); + assert.equal(result.headers['Mcp-Session-Id'], undefined); + }); + + it('does not read the request body before rejecting', async () => { + const handler = createHarperHttpHandler('application'); + let bodyRead = false; + const body = { + on(event, listener) { + if (event === 'data') bodyRead = true; + if (event === 'end') queueMicrotask(listener); + return body; + }, + }; + + await handler(deferredRequest({ body }), next); + + assert.equal(bodyRead, false, 'the body must not be consumed once the credential is rejected'); + }); + + it('still serves a request with no deferred rejection', async () => { + const handler = createHarperHttpHandler('application'); + const request = { + method: 'POST', + headers: makeHeaders({ 'content-type': 'application/json' }), + body: bodyStream( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } }) + ), + user: { username: 'alice' }, + }; + + const result = await handler(request, next); + + assert.equal(result.status, 200); + assert.match(result.headers['Mcp-Session-Id'], /^[0-9a-f-]{36}$/); + }); + + it('still lets a WebSocket upgrade through to the next handler', async () => { + const handler = createHarperHttpHandler('application'); + + const out = await handler(deferredRequest({ method: 'GET', isWebSocket: true }), () => 'next-handler-result'); + + assert.equal(out, 'next-handler-result'); + }); + }); + it('hands off WebSocket-upgrade requests to the next handler', async () => { const handler = createHarperHttpHandler('application'); const out = await handler( diff --git a/unitTests/security/authCredentialDeferral.test.js b/unitTests/security/authCredentialDeferral.test.js new file mode 100644 index 0000000000..e8f28758df --- /dev/null +++ b/unitTests/security/authCredentialDeferral.test.js @@ -0,0 +1,477 @@ +const assert = require('node:assert'); + +const testUtils = require('../testUtils.js'); +testUtils.preTestPrep(); + +const { makeCallbackChain } = require('#src/server/middlewareChain'); +const { Headers } = require('#src/server/serverHelpers/Headers'); +const { credentialRejectionError, settleDeferredCredentialRejection } = require('#src/security/deferredAuthentication'); +const { ClientError, ServerError } = require('#src/utility/errors/hdbError'); +const serverModule = require('#src/server/Server'); +const resourcesModule = require('#src/resources/Resources'); +const tokenAuthentication = require('#src/security/tokenAuthentication'); +const { authentication } = require('#src/security/auth'); + +const HARPER_OWNED = '/Ledger/1'; +const HARPER_OWNED_PUBLIC = '/PublicNotice/1'; +const APP_OWNED = '/wp-json/wc/v3/products'; + +const WORDPRESS_BASIC = `Basic ${Buffer.from('wordpress:abcd efgh ijkl mnop qrst uvwx').toString('base64')}`; +const HARPER_BASIC = `Basic ${Buffer.from('harper_admin:harper-pw').toString('base64')}`; +const DOWNSTREAM_BEARER = 'Bearer eyJhbGciOiJIUzI1NiJ9.d29vLXNlc3Npb24.not-a-harper-token'; + +function makeRequest(pathname, authorization, extra = {}) { + const headerObject = authorization ? { authorization } : {}; + return { + method: 'GET', + url: pathname, + pathname, + ip: '203.0.113.7', + headers: { + asObject: headerObject, + get: (name) => headerObject[name.toLowerCase()], + }, + peerCertificate: { subject: null }, + ...extra, + }; +} + +describe('deferred credential rejection through the app-port middleware chain', () => { + let originalGetUser; + let originalValidateOperationToken; + let originalValidateRefreshToken; + let trace; + let ownedPaths; + let knownUsers; + let getUserFault; + + function restLayer(request, nextHandler) { + if (!ownedPaths.has(request.pathname)) return nextHandler(request); + trace.push('rest'); + const settled = settleDeferredCredentialRejection(request); + if (settled) return settled; + if (!request.user && request.pathname !== HARPER_OWNED_PUBLIC) + return { status: 401, headers: new Headers(), body: JSON.stringify({ error: 'Login failed' }) }; + return { + status: 200, + headers: new Headers(), + body: JSON.stringify({ servedBy: 'rest', user: request.user?.username ?? null }), + }; + } + + let catchAllResponse; + + function applicationCatchAll(request) { + trace.push('catch-all'); + if (catchAllResponse) return catchAllResponse(); + return { + status: 200, + headers: new Headers(), + body: JSON.stringify({ + servedBy: 'catch-all', + authorization: request.headers.asObject.authorization ?? null, + harperUser: request.user?.username ?? null, + }), + }; + } + + const chain = makeCallbackChain( + [ + { listener: applicationCatchAll, port: 'all', name: 'applicationCatchAll', after: 'rest' }, + { listener: authentication, port: 'all', name: 'authentication' }, + { listener: restLayer, port: 'all', name: 'rest', after: 'authentication' }, + ], + 'all', + () => ({ status: 404, headers: new Headers(), body: 'Not found' }) + ); + + async function send(pathname, authorization, extra) { + const request = makeRequest(pathname, authorization, extra); + const response = await chain(request); + return { request, response, body: response?.body ? JSON.parse(response.body) : undefined }; + } + + before(() => { + originalGetUser = serverModule.server.getUser; + originalValidateOperationToken = tokenAuthentication.validateOperationToken; + originalValidateRefreshToken = tokenAuthentication.validateRefreshToken; + + serverModule.server.getUser = async (username, password) => { + if (getUserFault) throw getUserFault; + const user = knownUsers.get(`${username}:${password}`); + if (!user) throw credentialRejectionError('Login failed', 401); + return user; + }; + tokenAuthentication.validateOperationToken = async () => { + throw credentialRejectionError('invalid token', 401); + }; + tokenAuthentication.validateRefreshToken = async () => { + throw credentialRejectionError('invalid token', 401); + }; + }); + + after(() => { + serverModule.server.getUser = originalGetUser; + tokenAuthentication.validateOperationToken = originalValidateOperationToken; + tokenAuthentication.validateRefreshToken = originalValidateRefreshToken; + }); + + before(() => { + if (!resourcesModule.resources) resourcesModule.resetResources(); + }); + + beforeEach(() => { + trace = []; + ownedPaths = new Set([HARPER_OWNED, HARPER_OWNED_PUBLIC]); + knownUsers = new Map([['harper_admin:harper-pw', { username: 'harper_admin', role: { permission: {} } }]]); + getUserFault = undefined; + catchAllResponse = undefined; + delete resourcesModule.resources.loginPath; + }); + + afterEach(() => { + delete resourcesModule.resources.loginPath; + }); + + it('resolves the chain as authentication -> rest -> application catch-all', async () => { + const { body } = await send(APP_OWNED, undefined); + + assert.deepStrictEqual(trace, ['catch-all']); + assert.strictEqual(body.servedBy, 'catch-all'); + + const owned = await send(HARPER_OWNED, HARPER_BASIC); + assert.deepStrictEqual(trace, ['catch-all', 'rest']); + assert.strictEqual(owned.body.servedBy, 'rest'); + }); + + it('authenticates valid Harper Basic credentials and serves the owned resource', async () => { + const { request, body, response } = await send(HARPER_OWNED, HARPER_BASIC); + + assert.strictEqual(response.status, 200); + assert.strictEqual(request.user.username, 'harper_admin'); + assert.strictEqual(body.user, 'harper_admin'); + assert.deepStrictEqual(trace, ['rest']); + }); + + it('hands an unrecognized WordPress Basic credential to the catch-all, byte-for-byte', async () => { + const { request, response, body } = await send(APP_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.status, 200); + assert.strictEqual(body.servedBy, 'catch-all'); + assert.strictEqual(body.authorization, WORDPRESS_BASIC); + assert.strictEqual(request.headers.asObject.authorization, WORDPRESS_BASIC); + assert.strictEqual(body.harperUser, null); + assert.strictEqual(request.user, undefined); + }); + + it('gives a downstream-owned Bearer token the same treatment as Basic', async () => { + const { response, body } = await send(APP_OWNED, DOWNSTREAM_BEARER); + + assert.strictEqual(response.status, 200); + assert.strictEqual(body.authorization, DOWNSTREAM_BEARER); + assert.strictEqual(body.harperUser, null); + }); + + it('lets a Harper refresh token keep its declined (-1) handling instead of deferring', async () => { + tokenAuthentication.validateRefreshToken = async () => ({ username: 'harper_admin' }); + try { + const { response } = await send(APP_OWNED, 'Bearer harper-refresh-token'); + + assert.strictEqual(response.status, -1); + assert.deepStrictEqual(trace, []); + } finally { + tokenAuthentication.validateRefreshToken = async () => { + throw credentialRejectionError('invalid token', 401); + }; + } + }); + + it('rejects an unrecognized credential at a Harper-owned route and never reaches the catch-all', async () => { + const { response, body } = await send(HARPER_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.status, 401); + assert.strictEqual(body.error, 'Login failed'); + assert.deepStrictEqual(trace, ['rest']); + assert.ok(!trace.includes('catch-all'), 'a Harper-owned route must not fall through to the application'); + }); + + it('rejects an invalid Harper Bearer token at a Harper-owned route', async () => { + const { response, body } = await send(HARPER_OWNED, DOWNSTREAM_BEARER); + + assert.strictEqual(response.status, 401); + assert.strictEqual(body.error, 'invalid token'); + assert.deepStrictEqual(trace, ['rest']); + }); + + it('reports an expired Harper token as 401 at a Harper-owned route, as it did before deferral', async () => { + tokenAuthentication.validateOperationToken = async () => { + throw credentialRejectionError('token expired', 403); + }; + try { + const { response, body } = await send(HARPER_OWNED, 'Bearer expired-harper-token'); + + assert.strictEqual(response.status, 401); + assert.strictEqual(body.error, 'token expired'); + assert.deepStrictEqual(trace, ['rest']); + } finally { + tokenAuthentication.validateOperationToken = async () => { + throw credentialRejectionError('invalid token', 401); + }; + } + }); + + it('does not downgrade a Harper-owned route to public just because the credential was unknown', async () => { + const anonymous = await send(HARPER_OWNED_PUBLIC, undefined); + assert.strictEqual(anonymous.response.status, 200); + + const withUnknownCredential = await send(HARPER_OWNED_PUBLIC, WORDPRESS_BASIC); + assert.strictEqual(withUnknownCredential.response.status, 401); + assert.strictEqual(withUnknownCredential.body.error, 'Login failed'); + }); + + it('defers a scheme Harper does not implement rather than continuing anonymously', async () => { + const digest = 'Digest username="wp", realm="site", response="0123456789abcdef"'; + const { request, response, body } = await send(APP_OWNED, digest); + + assert.strictEqual(response.status, 200); + assert.strictEqual(body.servedBy, 'catch-all'); + assert.strictEqual(body.authorization, digest); + assert.strictEqual(request.user, undefined); + }); + + it('rejects a scheme Harper does not implement at an anonymously-readable Harper route', async () => { + const anonymous = await send(HARPER_OWNED_PUBLIC, undefined); + assert.strictEqual(anonymous.response.status, 200); + + const { response, body } = await send(HARPER_OWNED_PUBLIC, 'Digest username="wp", response="deadbeef"'); + assert.strictEqual(response.status, 401); + assert.strictEqual(body.error, 'Login failed'); + assert.deepStrictEqual(trace, ['rest', 'rest']); + }); + + it('treats an Authorization header with no scheme token as an unrecognized credential', async () => { + const { response } = await send(HARPER_OWNED_PUBLIC, 'aGFyZGx5LWEtc2NoZW1l'); + + assert.strictEqual(response.status, 401); + assert.deepStrictEqual(trace, ['rest']); + }); + + it('fails an unimplemented scheme closed in place on the operations API', async () => { + const { response } = await send(APP_OWNED, 'Digest username="wp"', { isOperationsServer: true }); + + assert.strictEqual(response.status, 401); + assert.deepStrictEqual(trace, []); + }); + + it('keeps the legacy blank Basic credential anonymous instead of deferring it', async () => { + const blank = `Basic ${Buffer.from(':').toString('base64')}`; + const { request, response, body } = await send(APP_OWNED, blank); + + assert.strictEqual(response.status, 200); + assert.strictEqual(body.servedBy, 'catch-all'); + assert.strictEqual(request.user, null); + const owned = await send(HARPER_OWNED_PUBLIC, blank); + assert.strictEqual(owned.response.status, 200); + }); + + it('leaves a request with no credentials completely unchanged', async () => { + const { request, response, body } = await send(APP_OWNED, undefined); + + assert.strictEqual(response.status, 200); + assert.strictEqual(body.authorization, null); + assert.strictEqual(request.user, undefined); + assert.deepStrictEqual(trace, ['catch-all']); + }); + + it('fails closed on an internal authentication fault instead of deferring', async () => { + getUserFault = new ServerError('user store unavailable'); + + const { response } = await send(APP_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.status, 401); + assert.deepStrictEqual(trace, []); + }); + + it('fails closed when the credential store raises a bare, unclassified error', async () => { + getUserFault = new Error('ENOENT: hdb_user'); + + const { response } = await send(APP_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.status, 401); + assert.deepStrictEqual(trace, []); + }); + + it('fails closed on an internal fault that happens to carry a 4xx status', async () => { + getUserFault = new ClientError('Table system.hdb_role not found'); + assert.strictEqual(getUserFault.statusCode, 400, 'the fault must actually be in the 4xx range'); + + const { response } = await send(APP_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.status, 401); + assert.deepStrictEqual(trace, [], 'a storage outage must never reach application authorization'); + }); + + it('returns a generic failure for an internal fault and does not echo its message', async () => { + getUserFault = new ClientError('Table system.hdb_role not found'); + + const { response, body } = await send(APP_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.status, 401); + assert.strictEqual(body.error, 'Login failed'); + assert.ok(!JSON.stringify(body).includes('hdb_role'), 'internal detail must not reach the client'); + }); + + it('propagates a refresh-validation fault instead of restoring the deferrable outer rejection', async () => { + tokenAuthentication.validateRefreshToken = async () => { + throw new ServerError('refresh token store unavailable'); + }; + try { + const { response, body } = await send(APP_OWNED, 'Bearer some-harper-looking-token'); + + assert.strictEqual(response.status, 401); + assert.strictEqual(body.error, 'Login failed'); + assert.deepStrictEqual(trace, [], 'a refresh-validation fault must not reach the catch-all'); + } finally { + tokenAuthentication.validateRefreshToken = async () => { + throw credentialRejectionError('invalid token', 401); + }; + } + }); + + it('propagates a refresh-validation fault that carries a 4xx status', async () => { + tokenAuthentication.validateRefreshToken = async () => { + throw new ClientError('Table system.hdb_user not found'); + }; + try { + const { response } = await send(APP_OWNED, 'Bearer some-harper-looking-token'); + + assert.strictEqual(response.status, 401); + assert.deepStrictEqual(trace, []); + } finally { + tokenAuthentication.validateRefreshToken = async () => { + throw credentialRejectionError('invalid token', 401); + }; + } + }); + + it('restores the operation-token rejection after an ordinary refresh rejection, and defers it', async () => { + const { response, body } = await send(APP_OWNED, DOWNSTREAM_BEARER); + + assert.strictEqual(response.status, 200); + assert.strictEqual(body.servedBy, 'catch-all'); + assert.strictEqual(body.authorization, DOWNSTREAM_BEARER); + }); + + it("answers a Harper-owned route with the authentication error envelope, not the owner's", async () => { + const { response, body } = await send(HARPER_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.status, 401); + assert.deepStrictEqual(body, { error: 'Login failed' }); + assert.strictEqual(response.headers.get('Content-Type'), 'application/json'); + }); + + it('never defers on the operations API, where Harper owns every route', async () => { + const { response } = await send(APP_OWNED, WORDPRESS_BASIC, { isOperationsServer: true }); + + assert.strictEqual(response.status, 401); + assert.deepStrictEqual(trace, []); + }); + + it('marks a deferred-credential response as identity-dependent for shared caches', async () => { + const { response } = await send(APP_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.headers.get('Vary').includes('Authorization'), true); + assert.strictEqual(response.headers.get('Cache-Control'), 'private, no-cache'); + }); + + it('does not stamp the identity floor on an ordinary anonymous pass-through', async () => { + const { response } = await send(APP_OWNED, undefined); + + assert.strictEqual(response.headers?.get?.('Cache-Control') ?? null, null); + }); + describe('401 post-processing ownership', () => { + const BROWSER = { + 'user-agent': 'Mozilla/5.0 (Macintosh)', + 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }; + + function browserRequestExtra(authorization) { + const headerObject = { ...BROWSER }; + if (authorization) headerObject.authorization = authorization; + return { + headers: { asObject: headerObject, get: (name) => headerObject[name.toLowerCase()] }, + }; + } + + async function sendBrowser(pathname, authorization) { + const request = makeRequest(pathname, authorization, browserRequestExtra(authorization)); + const response = await chain(request); + return { request, response }; + } + + it("leaves an application catch-all's own 401 challenge untouched", async () => { + catchAllResponse = () => ({ + status: 401, + headers: new Headers({ 'WWW-Authenticate': 'Basic realm="WooCommerce", charset="UTF-8"' }), + body: JSON.stringify({ code: 'woocommerce_rest_authentication_error' }), + }); + + const { response } = await send(APP_OWNED, WORDPRESS_BASIC); + + assert.deepStrictEqual(trace, ['catch-all']); + assert.strictEqual(response.status, 401); + assert.strictEqual(response.headers.get('WWW-Authenticate'), 'Basic realm="WooCommerce", charset="UTF-8"'); + }); + + it("does not redirect a browser to Harper's login page over an application-owned 401", async () => { + resourcesModule.resources.loginPath = () => '/login'; + catchAllResponse = () => ({ + status: 401, + headers: new Headers({ 'WWW-Authenticate': 'Bearer realm="woo"' }), + body: JSON.stringify({ code: 'woocommerce_rest_authentication_error' }), + }); + + const { response } = await sendBrowser(APP_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.status, 401); + assert.strictEqual(response.headers.get('Location') ?? null, null); + assert.strictEqual(response.headers.get('WWW-Authenticate'), 'Bearer realm="woo"'); + }); + + it('still applies the identity floor to an application-owned 401', async () => { + catchAllResponse = () => ({ status: 401, headers: new Headers(), body: '{}' }); + + const { response } = await send(APP_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.headers.get('Cache-Control'), 'private, no-cache'); + assert.ok(response.headers.get('Vary').includes('Authorization')); + }); + + it('keeps a settled Harper-owned rejection wire-identical to the in-line 401 it replaced', async () => { + resourcesModule.resources.loginPath = () => '/login'; + + const { response } = await sendBrowser(HARPER_OWNED, WORDPRESS_BASIC); + + assert.strictEqual(response.status, 401); + assert.strictEqual(response.headers.get('Location') ?? null, null); + assert.strictEqual(response.headers.get('WWW-Authenticate') ?? null, null); + assert.deepStrictEqual(JSON.parse(response.body), { error: 'Login failed' }); + }); + + it('still redirects a browser with no credentials at all to the login page', async () => { + resourcesModule.resources.loginPath = () => '/login'; + + const { response } = await sendBrowser(HARPER_OWNED, undefined); + + assert.strictEqual(response.status, 302); + assert.strictEqual(response.headers.get('Location'), '/login'); + }); + + it('still challenges an uncredentialed non-browser 401 with WWW-Authenticate', async () => { + const { response } = await send(HARPER_OWNED, undefined); + + assert.strictEqual(response.status, 401); + assert.strictEqual(response.headers.get('WWW-Authenticate'), 'Basic'); + }); + }); +}); diff --git a/unitTests/security/deferredAuthentication.test.js b/unitTests/security/deferredAuthentication.test.js new file mode 100644 index 0000000000..b1c744b2c4 --- /dev/null +++ b/unitTests/security/deferredAuthentication.test.js @@ -0,0 +1,248 @@ +const assert = require('node:assert'); + +const { + assertNoDeferredCredentialRejection, + credentialRejectionError, + deferCredentialRejection, + getDeferredCredentialRejection, + isCredentialRejection, + markCredentialRejection, + settleDeferredCredentialRejection, +} = require('#src/security/deferredAuthentication'); +const { ClientError, ServerError } = require('#src/utility/errors/hdbError'); + +function requestAccepting(accept, extraHeaders = {}) { + const asObject = { ...extraHeaders }; + if (accept) asObject.accept = accept; + return { + method: 'GET', + url: '/Ledger/1', + headers: { asObject, get: (name) => asObject[name.toLowerCase()] }, + }; +} + +describe('deferredAuthentication', () => { + describe('isCredentialRejection', () => { + it('accepts only an error tagged at the point authentication rejected the credential', () => { + assert.strictEqual(isCredentialRejection(credentialRejectionError('Login failed', 401)), true); + assert.strictEqual(isCredentialRejection(credentialRejectionError('token expired', 403)), true); + assert.strictEqual(isCredentialRejection(markCredentialRejection(new Error('invalid token'))), true); + }); + + it('never infers rejection from the 4xx range', () => { + assert.strictEqual(isCredentialRejection(new ClientError('Table system.hdb_role not found')), false); + assert.strictEqual(isCredentialRejection(new ClientError('Login failed', 401)), false); + assert.strictEqual(isCredentialRejection(new ClientError('token expired', 403)), false); + assert.strictEqual(isCredentialRejection({ statusCode: 401 }), false); + assert.strictEqual(isCredentialRejection({ status: 401 }), false); + }); + + it('rejects internal faults so they fail closed instead of deferring', () => { + assert.strictEqual(isCredentialRejection(new ClientError('no encryption keys', 500)), false); + assert.strictEqual(isCredentialRejection(new ServerError('storage unavailable')), false); + assert.strictEqual(isCredentialRejection(new Error('ENOENT')), false); + assert.strictEqual(isCredentialRejection(new TypeError('Invalid character')), false); + assert.strictEqual(isCredentialRejection(undefined), false); + assert.strictEqual(isCredentialRejection(null), false); + }); + + it('cannot be forged from outside the module', () => { + const forged = { + 'credentialRejection': true, + 'harper.credentialRejection': true, + [Symbol.for('harper.credentialRejection')]: true, + }; + + assert.strictEqual(isCredentialRejection(forged), false); + }); + + it('leaves the tag off the wire: it is neither enumerable nor serializable', () => { + const error = credentialRejectionError('Login failed', 401); + + assert.deepStrictEqual(Object.keys(error), ['statusCode']); + assert.deepStrictEqual(Object.getOwnPropertySymbols({ ...error }), []); + assert.strictEqual(JSON.stringify({ ...error }), '{"statusCode":401}'); + }); + }); + + describe('deferCredentialRejection', () => { + it('installs the state as a non-enumerable own property', () => { + const request = { headers: { authorization: 'Basic d3A6c2VjcmV0' } }; + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + + const stateSymbol = Object.getOwnPropertySymbols(request).find( + (symbol) => symbol.description === 'harper.deferredCredentialRejection' + ); + assert.ok(stateSymbol, 'the deferred state should be recorded under its own symbol'); + const descriptor = Object.getOwnPropertyDescriptor(request, stateSymbol); + assert.strictEqual(descriptor.enumerable, false); + }); + + it('cannot be cleared or changed by downstream middleware using reflection', () => { + const request = requestAccepting('application/json'); + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + const stateSymbol = Object.getOwnPropertySymbols(request).find( + (symbol) => symbol.description === 'harper.deferredCredentialRejection' + ); + + Reflect.deleteProperty(request, stateSymbol); + Reflect.set(request, stateSymbol, undefined); + const state = request[stateSymbol]; + if (state) Reflect.set(state, 'status', 200); + + const settled = settleDeferredCredentialRejection(request); + assert.strictEqual(settled.status, 401); + assert.deepStrictEqual(JSON.parse(settled.body.toString()), { error: 'Login failed' }); + }); + + it('does not survive object spread into a downstream application copy', () => { + const request = { headers: { authorization: 'Basic d3A6c2VjcmV0' } }; + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + + const copied = { ...request }; + + assert.deepStrictEqual(Object.getOwnPropertySymbols(copied), []); + assert.strictEqual(getDeferredCredentialRejection(copied), undefined); + }); + + it('stays out of Object.keys and JSON.stringify', () => { + const request = { headers: { authorization: 'Basic d3A6c2VjcmV0' } }; + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + + assert.deepStrictEqual(Object.keys(request), ['headers']); + assert.strictEqual(JSON.stringify(request), '{"headers":{"authorization":"Basic d3A6c2VjcmV0"}}'); + }); + + it('leaves the inbound Authorization header byte-for-byte unchanged', () => { + const authorization = 'Basic d29yZHByZXNzOmFiY2QgZWZnaCBpamtsIG1ub3AgcXJzdCB1dnd4'; + const request = { headers: { authorization } }; + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + + assert.strictEqual(request.headers.authorization, authorization); + }); + + it('pins the deferred status to 401 even when the underlying rejection was a 403', () => { + const request = {}; + deferCredentialRejection(request, credentialRejectionError('token expired', 403), 'Bearer'); + + assert.deepStrictEqual(getDeferredCredentialRejection(request), { + status: 401, + message: 'token expired', + strategy: 'Bearer', + }); + }); + + it('falls back to a generic message when the rejection carries none', () => { + const request = {}; + deferCredentialRejection(request, {}, 'Bearer'); + + assert.strictEqual(getDeferredCredentialRejection(request).message, 'Unauthorized'); + }); + + it('preserves the first rejection when multiple route chains authenticate the request', () => { + const request = {}; + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + deferCredentialRejection(request, credentialRejectionError('invalid token', 401), 'Bearer'); + + assert.deepStrictEqual(getDeferredCredentialRejection(request), { + status: 401, + message: 'Login failed', + strategy: 'Basic', + }); + }); + + it('is readable through a proxy of the request, as the urlPath-mount chain produces', () => { + const request = {}; + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + const proxied = new Proxy(request, { get: (target, prop) => Reflect.get(target, prop) }); + + assert.strictEqual(getDeferredCredentialRejection(proxied).status, 401); + }); + }); + + describe('getDeferredCredentialRejection', () => { + it('returns undefined for a request that presented no credential', () => { + assert.strictEqual(getDeferredCredentialRejection({}), undefined); + assert.strictEqual(getDeferredCredentialRejection(undefined), undefined); + }); + + it('cannot be forged through a string or well-known symbol key', () => { + const forged = { + 'deferredCredentialRejection': { status: 401, message: 'forged', strategy: 'Basic' }, + 'harper.deferredCredentialRejection': { status: 401, message: 'forged', strategy: 'Basic' }, + [Symbol.for('harper.deferredCredentialRejection')]: { status: 401, message: 'forged', strategy: 'Basic' }, + }; + + assert.strictEqual(getDeferredCredentialRejection(forged), undefined); + }); + }); + + describe('settleDeferredCredentialRejection', () => { + it('returns undefined when nothing was deferred', () => { + assert.strictEqual(settleDeferredCredentialRejection(requestAccepting('application/json')), undefined); + }); + + it('returns a real Headers, which the middleware 401 post-processing writes into', () => { + const request = requestAccepting('application/json'); + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + + const settled = settleDeferredCredentialRejection(request); + + assert.strictEqual(typeof settled.headers.set, 'function'); + assert.doesNotThrow(() => settled.headers.set('WWW-Authenticate', 'Basic')); + assert.strictEqual(settled.headers.get('WWW-Authenticate'), 'Basic'); + }); + + it('reproduces the authentication middleware response: 401 with an {error} body', () => { + const request = requestAccepting('application/json'); + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + + const settled = settleDeferredCredentialRejection(request); + + assert.strictEqual(settled.status, 401); + assert.strictEqual(settled.headers.get('Content-Type'), 'application/json'); + assert.deepStrictEqual(JSON.parse(settled.body.toString()), { error: 'Login failed' }); + }); + + it('serializes in the content type the request negotiated', () => { + const request = requestAccepting('application/cbor'); + deferCredentialRejection(request, credentialRejectionError('invalid token', 401), 'Bearer'); + + const settled = settleDeferredCredentialRejection(request); + + assert.strictEqual(settled.headers.get('Content-Type'), 'application/cbor'); + assert.ok(Buffer.isBuffer(settled.body), 'a CBOR body should be binary, not a JSON string'); + }); + + it('carries the underlying rejection message, not a fixed one', () => { + const request = requestAccepting('application/json'); + deferCredentialRejection(request, credentialRejectionError('token expired', 403), 'Bearer'); + + const settled = settleDeferredCredentialRejection(request); + + assert.strictEqual(settled.status, 401); + assert.deepStrictEqual(JSON.parse(settled.body.toString()), { error: 'token expired' }); + }); + }); + + describe('assertNoDeferredCredentialRejection', () => { + it('does nothing for a request with no deferred rejection', () => { + assert.doesNotThrow(() => assertNoDeferredCredentialRejection({})); + }); + + it('throws the unauthorized ClientError an owning Harper layer renders', () => { + const request = {}; + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + + assert.throws( + () => assertNoDeferredCredentialRejection(request), + (error) => { + assert.ok(error instanceof ClientError); + assert.strictEqual(error.statusCode, 401); + assert.strictEqual(error.message, 'Login failed'); + return true; + } + ); + }); + }); +}); diff --git a/unitTests/security/tokenRejectionClassification.test.js b/unitTests/security/tokenRejectionClassification.test.js new file mode 100644 index 0000000000..d79410d50b --- /dev/null +++ b/unitTests/security/tokenRejectionClassification.test.js @@ -0,0 +1,205 @@ +'use strict'; + +const testUtils = require('../testUtils.js'); +testUtils.preTestPrep(); + +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { generateKeyPairSync } = require('node:crypto'); +const jwt = require('jsonwebtoken'); + +const { JWT_ENUM, LICENSE_KEY_DIR_NAME } = require('#src/utility/hdbTerms'); +const env = require('#src/utility/environment/environmentManager'); +const { isCredentialRejection } = require('#src/security/deferredAuthentication'); +const { + clearJWTRSAKeysCache, + validateOperationToken, + validateRefreshToken, +} = require('#src/security/tokenAuthentication'); +const { setUsersWithRolesCache } = require('#src/security/user'); + +const KNOWN_USER = new Map([['known_user', { username: 'known_user', active: true, role: { permission: {} } }]]); + +async function raisedBy(fn) { + try { + await fn(); + } catch (error) { + return error; + } + assert.fail('expected the call to raise'); +} + +describe('token rejection versus internal authentication fault', () => { + let removeJwtKeys; + let signingKey; + let publicKeyPath; + let installedPublicKey; + let otherKeyPair; + + before(async () => { + // The keys land in a directory another suite asserts is empty, so they must come back out — + // see testUtils.installTestJwtKeys. It also resolves the keys directory from the base path + // current at call time, which is what makes this suite order-independent in the full run. + removeJwtKeys = testUtils.installTestJwtKeys(); + clearJWTRSAKeysCache(); + + const keysDir = path.join(env.getHdbBasePath(), LICENSE_KEY_DIR_NAME); + publicKeyPath = path.join(keysDir, JWT_ENUM.JWT_PUBLIC_KEY_NAME); + installedPublicKey = fs.readFileSync(publicKeyPath, 'utf8'); + signingKey = { + key: fs.readFileSync(path.join(keysDir, JWT_ENUM.JWT_PRIVATE_KEY_NAME), 'utf8'), + passphrase: fs.readFileSync(path.join(keysDir, JWT_ENUM.JWT_PASSPHRASE_NAME), 'utf8'), + }; + otherKeyPair = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + await setUsersWithRolesCache(new Map(KNOWN_USER)); + }); + + after(async () => { + removeJwtKeys(); + clearJWTRSAKeysCache(); + await setUsersWithRolesCache(new Map()); + }); + + // The internal-fault cases replace the installed public key in place; restore it so each case + // starts from usable key material without touching any file installTestJwtKeys does not own. + beforeEach(() => { + fs.writeFileSync(publicKeyPath, installedPublicKey); + clearJWTRSAKeysCache(); + }); + + function sign(claims, options = {}, key = signingKey) { + return jwt.sign(claims, key, { algorithm: 'RS256', ...options }); + } + + function replacePublicKey(publicKeyMaterial) { + fs.writeFileSync(publicKeyPath, publicKeyMaterial); + clearJWTRSAKeysCache(); + } + + describe('tagged credential rejections', () => { + it('classifies a syntactically malformed token as a rejection', async () => { + const error = await raisedBy(() => validateOperationToken('not-a-jwt')); + + assert.strictEqual(isCredentialRejection(error), true); + assert.strictEqual(error.message, 'invalid token'); + assert.strictEqual(error.statusCode, 401); + }); + + it('classifies a signature forged with another key as a rejection', async () => { + const forged = sign({ username: 'known_user' }, { subject: 'operation' }, otherKeyPair.privateKey); + + const error = await raisedBy(() => validateOperationToken(forged)); + + assert.strictEqual(isCredentialRejection(error), true); + assert.strictEqual(error.message, 'invalid token'); + }); + + it('classifies a wrong-subject token as a rejection', async () => { + const refreshToken = sign({ username: 'known_user' }, { subject: 'refresh' }); + + const error = await raisedBy(() => validateOperationToken(refreshToken)); + + assert.strictEqual(isCredentialRejection(error), true); + assert.strictEqual(error.message, 'invalid token'); + }); + + it('classifies an expired token as a rejection', async () => { + const expired = sign({ username: 'known_user' }, { subject: 'operation', expiresIn: '-1s' }); + + const error = await raisedBy(() => validateOperationToken(expired)); + + assert.strictEqual(isCredentialRejection(error), true); + assert.strictEqual(error.message, 'token expired'); + assert.strictEqual(error.statusCode, 403); + }); + + it('classifies a not-yet-valid token as a rejection', async () => { + const notYet = sign({ username: 'known_user' }, { subject: 'operation', notBefore: '1h' }); + + const error = await raisedBy(() => validateOperationToken(notYet)); + + assert.strictEqual(isCredentialRejection(error), true); + assert.strictEqual(error.message, 'invalid token'); + }); + + it('classifies a deactivated user as a credential-state rejection', async () => { + await setUsersWithRolesCache(new Map([['retired_user', { username: 'retired_user', active: false }]])); + try { + const token = sign({ username: 'retired_user' }, { subject: 'operation' }); + + const error = await raisedBy(() => validateOperationToken(token)); + + assert.strictEqual(isCredentialRejection(error), true); + } finally { + await setUsersWithRolesCache(new Map(KNOWN_USER)); + } + }); + + it('classifies a refresh token whose stored hash does not match as a rejection', async () => { + const refreshToken = sign({ username: 'known_user' }, { subject: 'refresh' }); + + const error = await raisedBy(() => validateRefreshToken(refreshToken)); + + assert.strictEqual(isCredentialRejection(error), true); + assert.strictEqual(error.message, 'invalid token'); + }); + + it('accepts a well-formed operation token, so the rejection cases mean something', async () => { + const valid = sign({ username: 'known_user' }, { subject: 'operation' }); + + const user = await validateOperationToken(valid); + + assert.strictEqual(user.username, 'known_user'); + }); + }); + + describe('internal faults', () => { + it('fails a non-PEM public key closed instead of reporting an invalid token', async () => { + const valid = sign({ username: 'known_user' }, { subject: 'operation' }); + replacePublicKey('this is not key material'); + + const error = await raisedBy(() => validateOperationToken(valid)); + + assert.strictEqual(isCredentialRejection(error), false); + assert.notStrictEqual(error.message, 'invalid token'); + assert.strictEqual(error.statusCode, 500); + }); + + it('fails a PEM-shaped but corrupt public key closed', async () => { + const valid = sign({ username: 'known_user' }, { subject: 'operation' }); + replacePublicKey('-----BEGIN PUBLIC KEY-----\nbm90LWEtcmVhbC1rZXk=\n-----END PUBLIC KEY-----\n'); + + const error = await raisedBy(() => validateOperationToken(valid)); + + assert.strictEqual(isCredentialRejection(error), false, `unexpectedly tagged: ${error.message}`); + assert.notStrictEqual(error.message, 'invalid token'); + }); + + it('propagates a user-store fault raised while resolving a validly signed token', async () => { + const failingCache = { + get() { + const error = new Error('Table system.hdb_user not found'); + error.statusCode = 400; + throw error; + }, + }; + await setUsersWithRolesCache(failingCache); + try { + const valid = sign({ username: 'known_user' }, { subject: 'operation' }); + + const error = await raisedBy(() => validateOperationToken(valid)); + + assert.strictEqual(isCredentialRejection(error), false); + assert.strictEqual(error.message, 'Table system.hdb_user not found'); + } finally { + await setUsersWithRolesCache(new Map(KNOWN_USER)); + } + }); + }); +}); diff --git a/unitTests/server/fallbackCacheFloor.test.js b/unitTests/server/fallbackCacheFloor.test.js new file mode 100644 index 0000000000..c6cb411746 --- /dev/null +++ b/unitTests/server/fallbackCacheFloor.test.js @@ -0,0 +1,418 @@ +'use strict'; + +const testUtils = require('../testUtils.js'); +testUtils.preTestPrep(); + +const assert = require('node:assert'); +const { Readable } = require('node:stream'); + +const { + bunDelegateToNodeServer, + httpServer, + makeUwsHandler, + registerFallbackServer, + registerFastifyInstance, +} = require('#src/server/http'); +const { + bridgeChainHeadersToNodeResponse, + Headers, + mergeChainHeadersIntoFallback, +} = require('#src/server/serverHelpers/Headers'); +const http = require('node:http'); +const Fastify = require('fastify'); + +const UWS_PORT = 19430; +const BUN_PORT = 19431; + +function identityFloorHeaders() { + return new Headers({ 'Cache-Control': 'private, no-cache', 'Vary': 'Authorization, Cookie' }); +} + +function fastifyReplying(statusCode, headers, body = 'ok') { + return { + inject: async () => ({ + statusCode, + headers, + stream: () => Readable.from([Buffer.from(body)]), + raw: {}, + }), + }; +} + +function uwsRequest(headers = {}) { + return { + method: 'GET', + url: '/wp-json/wc/v3/products', + pathname: '/wp-json/wc/v3/products', + headers: { asObject: headers, get: (name) => headers[name.toLowerCase()] }, + body: undefined, + }; +} + +function bunWebRequest() { + return new globalThis.Request('http://localhost/wp-json/wc/v3/products', { + method: 'GET', + headers: { authorization: 'Basic d29yZHByZXNzOnNlY3JldA==' }, + }); +} + +describe('legacy Fastify fallback preserves the chain cache floor', () => { + describe('uWS adapter', () => { + function handlerDecliningWith(chainHeaders, fastify) { + httpServer(() => ({ status: -1, headers: chainHeaders, body: 'Not found' }), { + port: UWS_PORT, + name: `uwsFallbackDecline${UWS_PORT}`, + }); + registerFastifyInstance(UWS_PORT, fastify); + return makeUwsHandler(UWS_PORT, false); + } + + it('carries Cache-Control and Vary from the chain onto the Fastify response', async () => { + const handle = handlerDecliningWith( + identityFloorHeaders(), + fastifyReplying(200, { 'content-type': 'application/json' }, '{"products":[]}') + ); + + const response = await handle(uwsRequest({ authorization: 'Basic d29yZHByZXNzOnNlY3JldA==' })); + + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers.get('Cache-Control'), 'private, no-cache'); + assert.strictEqual(response.headers.get('Vary'), 'Authorization, Cookie'); + assert.strictEqual(response.headers.get('content-type'), 'application/json'); + }); + + it('unions Vary rather than letting either side win outright', async () => { + const handle = handlerDecliningWith( + identityFloorHeaders(), + fastifyReplying(200, { 'vary': 'Accept-Encoding', 'content-type': 'text/plain' }) + ); + + const response = await handle(uwsRequest()); + + const vary = response.headers.get('Vary'); + for (const token of ['Accept-Encoding', 'Authorization', 'Cookie']) { + assert.ok(vary.includes(token), `Vary should include ${token}, got '${vary}'`); + } + }); + + it('re-applies the private scope when Fastify returns a cacheable response', async () => { + const handle = handlerDecliningWith( + identityFloorHeaders(), + fastifyReplying(200, { 'cache-control': 'max-age=600', 'content-type': 'text/html' }) + ); + + const response = await handle(uwsRequest()); + + assert.strictEqual(response.headers.get('Cache-Control'), 'max-age=600, private'); + }); + + it('honours an explicit shared-cache opt-in from the final response', async () => { + const handle = handlerDecliningWith( + identityFloorHeaders(), + fastifyReplying(200, { 'cache-control': 'public, max-age=600' }) + ); + + const response = await handle(uwsRequest()); + + assert.strictEqual(response.headers.get('Cache-Control'), 'public, max-age=600'); + }); + + it('keeps the floor on the 404 it produces when no fallback is registered', async () => { + httpServer(() => ({ status: -1, headers: identityFloorHeaders(), body: 'Not found' }), { + port: UWS_PORT + 5, + name: `uwsFallbackNoFastify${UWS_PORT}`, + }); + const handle = makeUwsHandler(UWS_PORT + 5, false); + + const response = await handle(uwsRequest()); + + assert.strictEqual(response.status, 404); + assert.strictEqual(response.headers.get('Cache-Control'), 'private, no-cache'); + assert.ok(response.headers.get('Vary').includes('Authorization')); + }); + }); + + describe('Bun adapter', () => { + it('carries the chain cache floor onto the delegated Fastify response', async () => { + const nodeServer = { bunFallback: 'uses reference identity' }; + registerFallbackServer(BUN_PORT, nodeServer); + registerFastifyInstance(BUN_PORT, fastifyReplying(200, { 'content-type': 'application/json' }, '{}')); + + const response = await bunDelegateToNodeServer( + nodeServer, + bunWebRequest(), + { user: undefined }, + identityFloorHeaders() + ); + + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers.get('cache-control'), 'private, no-cache'); + assert.strictEqual(response.headers.get('vary'), 'Authorization, Cookie'); + assert.strictEqual(response.headers.get('content-type'), 'application/json'); + }); + + it('unions Vary and re-applies the private scope over a cacheable Fastify response', async () => { + const nodeServer = { bunFallback: 'second instance' }; + registerFallbackServer(BUN_PORT + 1, nodeServer); + registerFastifyInstance( + BUN_PORT + 1, + fastifyReplying(200, { 'vary': 'Accept-Encoding', 'cache-control': 'max-age=600' }) + ); + + const response = await bunDelegateToNodeServer( + nodeServer, + bunWebRequest(), + { user: undefined }, + identityFloorHeaders() + ); + + assert.strictEqual(response.headers.get('cache-control'), 'max-age=600, private'); + const vary = response.headers.get('vary'); + for (const token of ['Accept-Encoding', 'Authorization', 'Cookie']) { + assert.ok(vary.includes(token), `Vary should include ${token}, got '${vary}'`); + } + }); + + it('leaves a response with no chain headers exactly as Fastify produced it', async () => { + const nodeServer = { bunFallback: 'third instance' }; + registerFallbackServer(BUN_PORT + 2, nodeServer); + registerFastifyInstance(BUN_PORT + 2, fastifyReplying(200, { 'cache-control': 'max-age=600' })); + + const response = await bunDelegateToNodeServer(nodeServer, bunWebRequest(), { user: undefined }, new Headers()); + + assert.strictEqual(response.headers.get('cache-control'), 'max-age=600'); + assert.strictEqual(response.headers.get('vary'), null); + }); + + it('keeps Fastify Set-Cookie fields separate', async () => { + const nodeServer = { bunFallback: 'multiple Fastify cookies' }; + registerFallbackServer(BUN_PORT + 3, nodeServer); + registerFastifyInstance( + BUN_PORT + 3, + fastifyReplying(200, { + 'set-cookie': ['session=app; Path=/', 'expires=soon; Expires=Wed, 21 Oct 2037 07:28:00 GMT; Path=/'], + }) + ); + + const response = await bunDelegateToNodeServer(nodeServer, bunWebRequest(), { user: undefined }, new Headers()); + + assert.deepStrictEqual(response.headers.getSetCookie(), [ + 'session=app; Path=/', + 'expires=soon; Expires=Wed, 21 Oct 2037 07:28:00 GMT; Path=/', + ]); + }); + }); + + describe('Node adapter', () => { + async function requestThroughFastify(chainHeaders, defineRoutes) { + const fastify = Fastify(); + defineRoutes(fastify); + await fastify.ready(); + const server = http.createServer((nodeRequest, nodeResponse) => { + bridgeChainHeadersToNodeResponse(chainHeaders, nodeResponse); + fastify.routing(nodeRequest, nodeResponse); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address(); + return await new Promise((resolve, reject) => { + const request = http.get({ host: '127.0.0.1', port, path: '/wp-json/wc/v3/products' }, (response) => { + response.resume(); + response.on('end', () => resolve(response)); + }); + request.on('error', reject); + }); + } finally { + server.close(); + await fastify.close(); + } + } + + it('re-applies the private scope over a Fastify route that replaced Cache-Control', async () => { + const response = await requestThroughFastify(identityFloorHeaders(), (fastify) => { + fastify.get('/wp-json/wc/v3/products', (_request, reply) => + reply.header('Cache-Control', 'max-age=600, must-revalidate').send('{}') + ); + }); + + assert.strictEqual(response.headers['cache-control'], 'max-age=600, must-revalidate, private'); + }); + + it('unions Vary with a Fastify route that replaced it', async () => { + const response = await requestThroughFastify(identityFloorHeaders(), (fastify) => { + fastify.get('/wp-json/wc/v3/products', (_request, reply) => reply.header('Vary', 'Accept-Encoding').send('{}')); + }); + + for (const token of ['Accept-Encoding', 'Authorization', 'Cookie']) { + assert.ok( + response.headers.vary.includes(token), + `Vary should include ${token}, got '${response.headers.vary}'` + ); + } + }); + + it('keeps the floor intact when the Fastify route sets neither header', async () => { + const response = await requestThroughFastify(identityFloorHeaders(), (fastify) => { + fastify.get('/wp-json/wc/v3/products', (_request, reply) => reply.send('{}')); + }); + + assert.strictEqual(response.headers['cache-control'], 'private, no-cache'); + assert.strictEqual(response.headers.vary, 'Authorization, Cookie'); + }); + + it("honours a Fastify route's explicit shared-cache opt-in", async () => { + const response = await requestThroughFastify(identityFloorHeaders(), (fastify) => { + fastify.get('/wp-json/wc/v3/products', (_request, reply) => + reply.header('Cache-Control', 'public, max-age=600').send('{}') + ); + }); + + assert.strictEqual(response.headers['cache-control'], 'public, max-age=600'); + }); + + it('carries the chain headers Fastify did not set, without overriding those it did', async () => { + const chainHeaders = identityFloorHeaders(); + chainHeaders.set('Access-Control-Allow-Origin', 'https://shop.example'); + const response = await requestThroughFastify(chainHeaders, (fastify) => { + fastify.get('/wp-json/wc/v3/products', (_request, reply) => + reply.header('Content-Type', 'application/json').send('{}') + ); + }); + + assert.strictEqual(response.headers['access-control-allow-origin'], 'https://shop.example'); + assert.ok(response.headers['content-type'].startsWith('application/json')); + }); + + it('reconciles a response written without an explicit writeHead', async () => { + const chainHeaders = identityFloorHeaders(); + const server = http.createServer((nodeRequest, nodeResponse) => { + bridgeChainHeadersToNodeResponse(chainHeaders, nodeResponse); + nodeResponse.setHeader('Cache-Control', 'max-age=600'); + nodeResponse.end('{}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address(); + const response = await new Promise((resolve, reject) => { + http + .get({ host: '127.0.0.1', port, path: '/' }, (res) => { + res.resume(); + res.on('end', () => resolve(res)); + }) + .on('error', reject); + }); + + assert.strictEqual(response.headers['cache-control'], 'max-age=600, private'); + } finally { + server.close(); + } + }); + + it('preserves repeated Set-Cookie fields passed through writeHead', async () => { + const server = http.createServer((_nodeRequest, nodeResponse) => { + bridgeChainHeadersToNodeResponse(identityFloorHeaders(), nodeResponse); + nodeResponse.writeHead(200, ['Set-Cookie', 'a=1; Path=/', 'Set-Cookie', 'b=2; Path=/']); + nodeResponse.end('{}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address(); + const response = await new Promise((resolve, reject) => { + http + .get({ host: '127.0.0.1', port, path: '/' }, (res) => { + res.resume(); + res.on('end', () => resolve(res)); + }) + .on('error', reject); + }); + + assert.deepStrictEqual(response.headers['set-cookie'], ['a=1; Path=/', 'b=2; Path=/']); + } finally { + server.close(); + } + }); + + it('does not duplicate a chain Set-Cookie field during writeHead reconciliation', async () => { + const chainHeaders = new Headers(); + chainHeaders.set('Set-Cookie', 'hdb-session=harper; Path=/; HttpOnly'); + const response = await requestThroughFastify(chainHeaders, (fastify) => { + fastify.get('/wp-json/wc/v3/products', (_request, reply) => reply.send('{}')); + }); + + assert.deepStrictEqual(response.headers['set-cookie'], ['hdb-session=harper; Path=/; HttpOnly']); + }); + + it('leaves a response alone when the chain produced no headers', async () => { + const response = await requestThroughFastify(new Headers(), (fastify) => { + fastify.get('/wp-json/wc/v3/products', (_request, reply) => + reply.header('Cache-Control', 'max-age=600').send('{}') + ); + }); + + assert.strictEqual(response.headers['cache-control'], 'max-age=600'); + assert.strictEqual(response.headers.vary, undefined); + }); + }); + + describe('mergeChainHeadersIntoFallback', () => { + it('never lets the chain overwrite a header the final response set', () => { + const chain = new Headers({ 'Content-Type': 'text/plain', 'X-From-Chain': 'yes' }); + const final = new Headers({ 'Content-Type': 'application/json' }); + + mergeChainHeadersIntoFallback(chain, final); + + assert.strictEqual(final.get('Content-Type'), 'application/json'); + assert.strictEqual(final.get('X-From-Chain'), 'yes'); + }); + + it('keeps a multi-valued Set-Cookie from the chain as separate values', () => { + const chain = new Headers(); + chain.set('Set-Cookie', ['a=1; Path=/', 'b=2; Path=/']); + const final = new Headers(); + + mergeChainHeadersIntoFallback(chain, final); + + assert.deepStrictEqual(final.get('Set-Cookie'), ['a=1; Path=/', 'b=2; Path=/']); + }); + + it('keeps both Fastify and chain Set-Cookie fields', () => { + const chain = new Headers(); + chain.set('Set-Cookie', 'hdb-session=harper; Path=/; HttpOnly'); + const final = new Headers(); + final.set('Set-Cookie', 'app-session=wordpress; Path=/; HttpOnly'); + + mergeChainHeadersIntoFallback(chain, final); + + assert.deepStrictEqual(final.get('Set-Cookie'), [ + 'app-session=wordpress; Path=/; HttpOnly', + 'hdb-session=harper; Path=/; HttpOnly', + ]); + }); + + it('does not duplicate a Vary token the final response already declares', () => { + const chain = new Headers({ Vary: 'Authorization, Cookie' }); + const final = new Headers({ Vary: 'Authorization' }); + + mergeChainHeadersIntoFallback(chain, final); + + assert.strictEqual(final.get('Vary'), 'Authorization, Cookie'); + }); + + it('leaves an existing private scope alone rather than appending a second one', () => { + const chain = new Headers({ 'Cache-Control': 'private, no-cache' }); + const final = new Headers({ 'Cache-Control': 'no-store' }); + + mergeChainHeadersIntoFallback(chain, final); + + assert.strictEqual(final.get('Cache-Control'), 'no-store'); + }); + + it('is a no-op when the chain produced no headers at all', () => { + const final = new Headers({ 'Cache-Control': 'max-age=60' }); + + mergeChainHeadersIntoFallback(undefined, final); + + assert.strictEqual(final.get('Cache-Control'), 'max-age=60'); + }); + }); +}); diff --git a/unitTests/server/httpChainPortAll.test.js b/unitTests/server/httpChainPortAll.test.js new file mode 100644 index 0000000000..ea2d9a0c56 --- /dev/null +++ b/unitTests/server/httpChainPortAll.test.js @@ -0,0 +1,70 @@ +'use strict'; + +const testUtils = require('../testUtils.js'); +testUtils.preTestPrep(); + +const assert = require('node:assert'); + +const { httpServer, describeMiddlewareChains } = require('#src/server/http'); +const { server } = require('#src/server/Server'); + +// Ports are never bound here: httpServer()/server.upgrade() only construct a server object and +// register a middleware entry, and threadServer.listenOnPorts() is what binds. Two distinct ports +// so the "an 'all' rebuild doesn't cross-contaminate concrete ports" case is observable. +const PORT = 19418; +const OTHER_PORT = 19419; + +const passThrough = (request, next) => next(request); + +/** + * The resolved order of the default (unmounted) route for `port`, restricted to this file's own + * entries — `httpResponders` is process-global, so a sibling unit test file loaded into the same + * mocha run can legitimately have registered entries of its own. + */ +function orderFor(kind, port) { + const routes = describeMiddlewareChains()[kind][port] ?? []; + const defaultRoute = routes.find((route) => !route.host && !route.urlPath); + return (defaultRoute?.order ?? []).filter((name) => name.startsWith('chainSync')); +} + +describe('http middleware chains and the "all" pseudo-port', () => { + it('folds a late port:"all" registration into concrete port chains that were already built', () => { + httpServer(passThrough, { port: PORT, name: 'chainSyncAuthentication' }); + httpServer(passThrough, { port: PORT, name: 'chainSyncRest', after: 'chainSyncAuthentication' }); + + assert.deepStrictEqual(orderFor('http', PORT), ['chainSyncAuthentication', 'chainSyncRest']); + + httpServer(passThrough, { port: 'all', name: 'chainSyncCatchAll', after: 'chainSyncRest' }); + + assert.deepStrictEqual(orderFor('http', PORT), ['chainSyncAuthentication', 'chainSyncRest', 'chainSyncCatchAll']); + }); + + it('applies a late port:"all" registration to every already-built port, not just the newest', () => { + httpServer(passThrough, { port: OTHER_PORT, name: 'chainSyncOtherPortRest' }); + httpServer(passThrough, { port: 'all', name: 'chainSyncSecondCatchAll', after: 'chainSyncOtherPortRest' }); + + assert.ok(orderFor('http', PORT).includes('chainSyncSecondCatchAll')); + assert.deepStrictEqual(orderFor('http', OTHER_PORT), [ + 'chainSyncCatchAll', + 'chainSyncOtherPortRest', + 'chainSyncSecondCatchAll', + ]); + }); + + it('does not leak a concrete port registration into another port chain', () => { + httpServer(passThrough, { port: OTHER_PORT, name: 'chainSyncOtherPortOnly' }); + + assert.ok(!orderFor('http', PORT).includes('chainSyncOtherPortOnly')); + assert.ok(orderFor('http', OTHER_PORT).includes('chainSyncOtherPortOnly')); + }); + + it('synchronizes upgrade chains on the same terms as http chains', () => { + server.upgrade(passThrough, { port: PORT, name: 'chainSyncUpgrade' }); + + assert.deepStrictEqual(orderFor('upgrade', PORT), ['chainSyncUpgrade']); + + server.upgrade(passThrough, { port: 'all', name: 'chainSyncUpgradeCatchAll', after: 'chainSyncUpgrade' }); + + assert.deepStrictEqual(orderFor('upgrade', PORT), ['chainSyncUpgrade', 'chainSyncUpgradeCatchAll']); + }); +}); diff --git a/unitTests/server/mqtt.test.js b/unitTests/server/mqtt.test.js index b742cd69cb..64ae39eceb 100644 --- a/unitTests/server/mqtt.test.js +++ b/unitTests/server/mqtt.test.js @@ -66,3 +66,122 @@ describe('mqtt.ts handleApplication raw-socket registration', () => { }); }); }); + +describe('mqtt.ts WebSocket listener settles authentication before the session starts', () => { + const { credentialRejectionError, deferCredentialRejection } = require('#src/security/deferredAuthentication'); + const { generate } = require('mqtt-packet'); + + function webSocketListener() { + let listener; + const server = { + ws: (fn) => ((listener = fn), []), + socket: () => ({}), + }; + handleApplication({ options: { getAll: () => ({ webSocket: {} }) }, server }); + return listener; + } + + function fakeWebSocket() { + const closes = []; + const sends = []; + const handlers = {}; + return { + closes, + sends, + handlers, + _socket: { remoteAddress: '127.0.0.1' }, + close: (code, reason) => closes.push({ code, reason }), + send: (message) => sends.push(message), + on: (event, handler) => { + handlers[event] = handler; + }, + }; + } + + function mqttUpgradeRequest(headers = {}) { + const asObject = { 'sec-websocket-protocol': 'mqtt', ...headers }; + return { headers: { asObject, get: (name) => asObject[name.toLowerCase()] } }; + } + + const settle = () => new Promise((resolve) => setImmediate(resolve)); + + it('closes the socket once an asynchronously-recorded credential rejection settles', async () => { + const listener = webSocketListener(); + const ws = fakeWebSocket(); + const request = mqttUpgradeRequest({ authorization: 'Basic d29yZHByZXNzOnNlY3JldA==' }); + const chainCompletion = (async () => { + await Promise.resolve(); + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + return { status: 200 }; + })(); + + listener(ws, request, chainCompletion, () => { + throw new Error('a mqtt-subprotocol upgrade must not fall through to the next listener'); + }); + + assert.deepStrictEqual(ws.closes, []); + await chainCompletion; + await settle(); + + assert.deepStrictEqual(ws.closes, [{ code: 3000, reason: 'Login failed' }]); + }); + + it('does not accept the subsequent CONNECT anonymously', async () => { + const listener = webSocketListener(); + const ws = fakeWebSocket(); + const request = mqttUpgradeRequest({ authorization: 'Basic d29yZHByZXNzOnNlY3JldA==' }); + const chainCompletion = (async () => { + await Promise.resolve(); + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + return { status: 200 }; + })(); + + listener(ws, request, chainCompletion, () => {}); + await chainCompletion; + await settle(); + + ws.handlers.message( + generate({ cmd: 'connect', protocolId: 'MQTT', protocolVersion: 4, clientId: 'woo-client', clean: true }) + ); + await settle(); + await settle(); + + assert.deepStrictEqual(ws.sends, [], 'no CONNACK may be sent for a rejected credential'); + assert.ok(ws.closes.length > 0, 'the connection must be closed rather than served anonymously'); + }); + + it('leaves an authenticated connection open and attaches its handlers synchronously', async () => { + const listener = webSocketListener(); + const ws = fakeWebSocket(); + const request = mqttUpgradeRequest({ authorization: 'Basic aGFycGVyOnB3' }); + const chainCompletion = (async () => { + await Promise.resolve(); + request.user = { username: 'harper_admin' }; + return { status: 200 }; + })(); + + listener(ws, request, chainCompletion, () => {}); + + assert.strictEqual(typeof ws.handlers.message, 'function'); + assert.strictEqual(typeof ws.handlers.close, 'function'); + await chainCompletion; + await settle(); + + assert.deepStrictEqual(ws.closes, []); + }); + + it('passes a non-mqtt subprotocol upgrade straight to the next listener', async () => { + const listener = webSocketListener(); + const ws = fakeWebSocket(); + const request = mqttUpgradeRequest({ 'sec-websocket-protocol': 'graphql-ws' }); + let forwarded = false; + + listener(ws, request, Promise.resolve({ status: 200 }), () => { + forwarded = true; + }); + await settle(); + + assert.strictEqual(forwarded, true); + assert.deepStrictEqual(ws.closes, []); + }); +}); diff --git a/unitTests/server/static.test.js b/unitTests/server/static.test.js index 3e2452221a..7873eb23c5 100644 --- a/unitTests/server/static.test.js +++ b/unitTests/server/static.test.js @@ -5,6 +5,11 @@ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require('node:fs'); const { join } = require('node:path'); const { tmpdir } = require('node:os'); const { handleApplication } = require('#src/server/static'); +const { + credentialRejectionError, + deferCredentialRejection, + getDeferredCredentialRejection, +} = require('#src/security/deferredAuthentication'); // A minimal Scope stand-in: captures the http registration and warning log so tests can // assert on the middleware ordering options the plugin passes to the server. @@ -368,10 +373,6 @@ describe('static plugin ordering live reload', () => { }); describe('static plugin mount-root redirect', () => { - // A root-level static plugin (no urlPath of its own) has baseURLPath === '/', so gating the - // redirect on baseURLPath alone never fires — even though the application mount makes the - // client-visible root something other than '/'. Review finding: the mount root then serves - // without ever redirecting to its trailing-slash form. it('redirects the application mount root to its trailing-slash form even when static has no urlPath of its own', () => { const { scope, state } = fakeScope({}, { urlPath: '/v1' }); handleApplication(scope); @@ -388,8 +389,6 @@ describe('static plugin mount-root redirect', () => { it('does not redirect when the application has no mount (root stays root)', () => { const { scope, state } = fakeScope(); handleApplication(scope); - // A real, existing path — with no mount, the redirect guard is false and this falls through - // to actually serving the file (realpathSync must succeed). state.entryCallback({ eventType: 'add', urlPath: '/index.html', absolutePath: __filename }); const result = state.listener({ method: 'GET', pathname: '/', url: '/', originalPathname: '/' }, () => ({ @@ -399,3 +398,206 @@ describe('static plugin mount-root redirect', () => { assert.notEqual(result.status, 301); }); }); + +function staticRequest(pathname, { url = pathname, originalPathname, authorization } = {}) { + const asObject = { accept: 'application/json' }; + if (authorization) asObject.authorization = authorization; + return { + method: 'GET', + isWebSocket: false, + pathname, + url, + originalPathname, + headers: { asObject, get: (name) => asObject[name.toLowerCase()] }, + }; +} + +const NEXT_HANDLER = Symbol('next handler'); +const next = () => NEXT_HANDLER; + +function assertSettledUnauthorized(result, request, authorization) { + assert.equal(result.status, 401, 'a static-owned response must settle the deferred rejection'); + assert.equal(result.headers.get('Content-Type'), 'application/json'); + assert.deepStrictEqual(JSON.parse(result.body.toString()), { error: 'Login failed' }); + assert.equal(request.headers.get('authorization'), authorization); +} + +describe('static plugin deferred credential rejection', () => { + const BASIC = 'Basic d29yZHByZXNzOmFwcC1wYXNzd29yZA=='; + const BEARER = 'Bearer downstream-owned-token'; + + function deferred(request) { + deferCredentialRejection(request, credentialRejectionError('Login failed', 401), 'Basic'); + return request; + } + + it('settles the mount-root redirect instead of answering 301', () => { + const { scope, state } = fakeScope({ after: 'rest' }, { urlPath: '/v1' }); + handleApplication(scope); + state.entryCallback({ eventType: 'add', urlPath: '/index.html', absolutePath: '/fake/app/web/index.html' }); + + const request = deferred(staticRequest('/', { originalPathname: '/v1', authorization: BASIC })); + const result = state.listener(request, next); + + assertSettledUnauthorized(result, request, BASIC); + }); + + it('settles the trailing-slash directory redirect instead of answering 301', () => { + const { scope, state } = fakeScope({ after: 'rest' }); + handleApplication(scope); + state.entryCallback({ + eventType: 'add', + urlPath: '/docs/index.html', + absolutePath: '/fake/app/web/docs/index.html', + }); + + const request = deferred(staticRequest('/docs', { authorization: BEARER })); + const result = state.listener(request, next); + + assertSettledUnauthorized(result, request, BEARER); + }); + + it('settles the built-in fallthrough: false 404 instead of answering "File not found"', () => { + const { scope, state } = fakeScope({ fallthrough: false, after: 'rest' }); + handleApplication(scope); + + const request = deferred(staticRequest('/wp-json/wc/v3/orders', { authorization: BASIC })); + const result = state.listener(request, next); + + assertSettledUnauthorized(result, request, BASIC); + }); + + it('settles the configured notFound response instead of serving the fallback page', () => { + const directory = mkdtempSync(join(tmpdir(), 'harper-static-notfound-')); + writeFileSync(join(directory, 'spa.html'), 'spa'); + + try { + const { scope, state } = fakeScope({ + fallthrough: false, + after: 'rest', + notFound: { file: 'spa.html', statusCode: 200 }, + }); + scope.directory = directory; + handleApplication(scope); + + const request = deferred(staticRequest('/app/route', { authorization: BEARER })); + const result = state.listener(request, next); + + assertSettledUnauthorized(result, request, BEARER); + assert.equal(result.handlesHeaders, undefined, 'the settled 401 is not the send() stream response'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('settles the ordinary file response', () => { + const { scope, state } = fakeScope({ after: 'rest' }); + handleApplication(scope); + state.entryCallback({ eventType: 'add', urlPath: '/asset.js', absolutePath: __filename }); + + const request = deferred(staticRequest('/asset.js', { authorization: BASIC })); + const result = state.listener(request, next); + + assertSettledUnauthorized(result, request, BASIC); + }); + + it('leaves the rejection deferred on the actual fallthrough so a downstream owner still decides', () => { + const { scope, state } = fakeScope({ after: 'rest' }); + handleApplication(scope); + + const request = deferred(staticRequest('/wp-json/wc/v3/orders', { authorization: BASIC })); + const result = state.listener(request, next); + + assert.strictEqual(result, NEXT_HANDLER); + assert.equal(getDeferredCredentialRejection(request).status, 401); + assert.equal(request.headers.get('authorization'), BASIC); + assert.equal(request.user, undefined); + }); + + it('leaves a non-GET request deferred', () => { + const { scope, state } = fakeScope({ fallthrough: false, after: 'rest' }); + handleApplication(scope); + + const request = deferred(staticRequest('/app/route', { authorization: BASIC })); + request.method = 'POST'; + + assert.strictEqual(state.listener(request, next), NEXT_HANDLER); + assert.equal(getDeferredCredentialRejection(request).status, 401); + }); +}); + +describe('static plugin responses without a deferred rejection', () => { + it('still redirects the mount root, preserving the query string', () => { + const { scope, state } = fakeScope({ after: 'rest' }, { urlPath: '/v1' }); + handleApplication(scope); + state.entryCallback({ eventType: 'add', urlPath: '/index.html', absolutePath: '/fake/app/web/index.html' }); + + const result = state.listener(staticRequest('/', { url: '/?a=1', originalPathname: '/v1' }), next); + + assert.equal(result.status, 301); + assert.equal(result.headers.Location, '/v1/?a=1'); + }); + + it('still redirects a directory to its trailing-slash form, preserving the query string', () => { + const { scope, state } = fakeScope({ after: 'rest' }); + handleApplication(scope); + state.entryCallback({ + eventType: 'add', + urlPath: '/docs/index.html', + absolutePath: '/fake/app/web/docs/index.html', + }); + + const result = state.listener(staticRequest('/docs', { url: '/docs?a=1' }), next); + + assert.equal(result.status, 301); + assert.equal(result.headers.Location, '/docs/?a=1'); + }); + + it('still rebuilds the directory redirect against an application mount', () => { + const { scope, state } = fakeScope({ after: 'rest' }, { urlPath: '/v1' }); + handleApplication(scope); + state.entryCallback({ + eventType: 'add', + urlPath: '/docs/index.html', + absolutePath: '/fake/app/web/docs/index.html', + }); + + const result = state.listener(staticRequest('/docs'), next); + + assert.equal(result.status, 301); + assert.equal(result.headers.Location, '/v1/docs/'); + }); + + it('still answers the built-in 404 body', () => { + const { scope, state } = fakeScope({ fallthrough: false, after: 'rest' }); + handleApplication(scope); + + const result = state.listener(staticRequest('/missing'), next); + + assert.equal(result.status, 404); + assert.equal(result.body, 'File not found'); + }); + + it('still serves the configured notFound file with its status code', () => { + const directory = mkdtempSync(join(tmpdir(), 'harper-static-notfound-ok-')); + writeFileSync(join(directory, 'spa.html'), 'spa'); + + try { + const { scope, state } = fakeScope({ + fallthrough: false, + after: 'rest', + notFound: { file: 'spa.html', statusCode: 200 }, + }); + scope.directory = directory; + handleApplication(scope); + + const result = state.listener(staticRequest('/app/route'), next); + + assert.equal(result.status, 200); + assert.equal(result.handlesHeaders, true); + assert.equal(typeof result.body.pipe, 'function'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +});