@x1id/resolve 0.15.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -366,10 +366,22 @@ export async function POST(req: Request) { // any server runtime with
366
366
  }
367
367
  ```
368
368
 
369
- Fail-closed by default (re-throws if the decision can't be obtained; pass
370
- `{ onError: "allow" }` to fail-open). `checkGate()` is the lower-level client for
371
- the full decision; pass a stored policy's `gp_…` id as the policy to reuse a named
372
- policy. The hosted check is keyed/metered — see `services/dev-api`'s README.
369
+ Fail-closed by default (re-throws if the decision can't be obtained). `onError:
370
+ "deny"` fails closed without throwing (clean `allowed: false`). `checkGate()` is
371
+ the lower-level client for the full decision; pass a stored policy's `gp_…` id as
372
+ the policy to reuse a named policy. The hosted check is keyed/metered — see
373
+ `services/dev-api`'s README.
374
+
375
+ > **⚠️ Never use `onError: "allow"` on a real gate.** It FAILS OPEN: on *any*
376
+ > error — a network blip, a timeout, a 5xx, an auth failure, a malformed
377
+ > response — it fabricates a `passed: true` decision and admits the caller
378
+ > **unverified**. Anyone who can make your gate call fail (trivially: induce a
379
+ > timeout) then bypasses the gate completely, so it is worse than no gate at all
380
+ > for anything it protects — a route, a mint, an airdrop, a write, an allowlist.
381
+ > It exists ONLY for soft, non-security personalization where a missing
382
+ > verification must not degrade UX (e.g. optionally showing a "verified"
383
+ > flourish). Using it logs a one-time `console.warn`. If the decision gates
384
+ > anything, use the default `"throw"` or `"deny"`.
373
385
 
374
386
  ## What works today
375
387
 
@@ -80,14 +80,16 @@ export interface GuardResult {
80
80
  * silently admitting an unverified caller. `onError: "deny"` instead resolves to
81
81
  * a clean `allowed: false` (fail-closed without a throw).
82
82
  *
83
- * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN. On ANY error (network blip,
84
- * timeout, 5xx, auth failure, a malformed response) it fabricates a
85
- * `passed: true` decision and admits the caller UNVERIFIED. NEVER use it for a
86
- * real access gate — anyone who can make your gate call fail (trivial: induce a
87
- * timeout) then bypasses it completely. It exists ONLY for soft, non-security
88
- * personalization where a missing verification must not degrade UX (e.g.
89
- * optionally showing a "verified" flourish). If the gate protects anything —
90
- * a route, a mint, an airdrop, a write — use the default `"throw"` or `"deny"`.
83
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN, and is NEVER acceptable on a real
84
+ * gate. On ANY error (network blip, timeout, 5xx, auth failure, a malformed
85
+ * response) it fabricates a `passed: true` decision and admits the caller
86
+ * UNVERIFIED — so anyone who can make your gate call fail (trivial: induce a
87
+ * timeout) bypasses it completely. That makes it strictly WORSE than no gate for
88
+ * anything it is supposed to protect: a route, a mint, an airdrop, a write, an
89
+ * allowlist. It exists ONLY for soft, non-security personalization where a
90
+ * missing verification must not degrade UX (e.g. optionally showing a "verified"
91
+ * flourish). Constructing a guard with it logs a one-time `console.warn`. If the
92
+ * decision gates ANYTHING, use the default `"throw"` or `"deny"` (fail-closed).
91
93
  *
92
94
  * @example
93
95
  * ```ts
@@ -81,14 +81,16 @@ export async function checkGate(cfg, subject, policy) {
81
81
  * silently admitting an unverified caller. `onError: "deny"` instead resolves to
82
82
  * a clean `allowed: false` (fail-closed without a throw).
83
83
  *
84
- * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN. On ANY error (network blip,
85
- * timeout, 5xx, auth failure, a malformed response) it fabricates a
86
- * `passed: true` decision and admits the caller UNVERIFIED. NEVER use it for a
87
- * real access gate — anyone who can make your gate call fail (trivial: induce a
88
- * timeout) then bypasses it completely. It exists ONLY for soft, non-security
89
- * personalization where a missing verification must not degrade UX (e.g.
90
- * optionally showing a "verified" flourish). If the gate protects anything —
91
- * a route, a mint, an airdrop, a write — use the default `"throw"` or `"deny"`.
84
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN, and is NEVER acceptable on a real
85
+ * gate. On ANY error (network blip, timeout, 5xx, auth failure, a malformed
86
+ * response) it fabricates a `passed: true` decision and admits the caller
87
+ * UNVERIFIED — so anyone who can make your gate call fail (trivial: induce a
88
+ * timeout) bypasses it completely. That makes it strictly WORSE than no gate for
89
+ * anything it is supposed to protect: a route, a mint, an airdrop, a write, an
90
+ * allowlist. It exists ONLY for soft, non-security personalization where a
91
+ * missing verification must not degrade UX (e.g. optionally showing a "verified"
92
+ * flourish). Constructing a guard with it logs a one-time `console.warn`. If the
93
+ * decision gates ANYTHING, use the default `"throw"` or `"deny"` (fail-closed).
92
94
  *
93
95
  * @example
94
96
  * ```ts
@@ -106,6 +108,10 @@ export async function checkGate(cfg, subject, policy) {
106
108
  */
107
109
  export function requireGate(cfg, policy, opts = {}) {
108
110
  const onError = opts.onError ?? "throw";
111
+ // Warn HARD (once) the moment a fail-open guard is built — `onError: "allow"`
112
+ // admits unverified callers on any error and must never guard anything real.
113
+ if (onError === "allow")
114
+ warnFailOpenOnce();
109
115
  return async (subject) => {
110
116
  try {
111
117
  const decision = await checkGate(cfg, subject, policy);
@@ -126,3 +132,16 @@ export function requireGate(cfg, policy, opts = {}) {
126
132
  }
127
133
  };
128
134
  }
135
+ /** Process-wide latch so the fail-open warning fires at most once, not on every
136
+ * guard construction. */
137
+ let failOpenWarned = false;
138
+ function warnFailOpenOnce() {
139
+ if (failOpenWarned)
140
+ return;
141
+ failOpenWarned = true;
142
+ // eslint-disable-next-line no-console
143
+ console.warn("[@x1id/resolve] requireGate({ onError: \"allow\" }) FAILS OPEN: on any gate-call " +
144
+ "error it admits the caller UNVERIFIED. Never use it to protect a route, mint, " +
145
+ "airdrop, write, or allowlist — use the default \"throw\" or \"deny\" instead. " +
146
+ "(This warning is for soft personalization-only uses; it logs once.)");
147
+ }
package/dist/signin.js CHANGED
@@ -78,6 +78,14 @@ export class SignInError extends Error {
78
78
  * belt-and-suspenders CR/LF guard in {@link createSignInChallenge} mirrors the
79
79
  * `uri`/`statement` fields and is the real anti-injection control. */
80
80
  const DOMAIN_RE = /^[A-Za-z0-9.-]{1,253}(:\d{1,5})?$/;
81
+ /** Any character a line-oriented parser could mistake for a boundary: every C0
82
+ * control (so `\n`, `\r`, `\t`, and the rest), DEL, and — the trap the old
83
+ * `\n`/`\r`-only guard missed — the Unicode line/paragraph separators
84
+ * U+2028/U+2029, which `^`/`$` under the regex `m` flag honor just like `\n`
85
+ * (see {@link parseSignInChallenge}'s field parser). FORBIDDEN in every value
86
+ * interpolated into a challenge line (domain, statement, uri) so a crafted
87
+ * value can never forge a second line — challenge-line injection (audit L5). */
88
+ const FORBIDDEN_LINE_CHAR_RE = /[\u0000-\u001f\u007f\u2028\u2029]/;
81
89
  /** Nonce charset — ASCII alphanumeric, matching the rest of the SDK's nonces so
82
90
  * it can never contain a separator or a newline. SIWE requires >= 8 chars. */
83
91
  const NONCE_RE = /^[0-9A-Za-z]{8,128}$/;
@@ -124,11 +132,14 @@ export function generateSignInNonce(bytes = 16) {
124
132
  */
125
133
  export function createSignInChallenge(params) {
126
134
  const { domain } = params;
127
- // Explicit CR/LF guard first — mirrors the `uri`/`statement` fields below and
128
- // makes the anti-challenge-injection intent impossible to regress even if the
129
- // charset in DOMAIN_RE is ever loosened.
130
- if (typeof domain !== "string" || domain.includes("\n") || domain.includes("\r") || !DOMAIN_RE.test(domain)) {
131
- throw new SignInError("invalid-params", "domain must be a single DNS host (letters, digits, '.', '-'), no newlines");
135
+ // Explicit control/line-terminator guard first — mirrors the `uri`/`statement`
136
+ // fields below and makes the anti-challenge-injection intent impossible to
137
+ // regress even if the charset in DOMAIN_RE is ever loosened. DOMAIN_RE already
138
+ // admits only `[A-Za-z0-9.-]` (+ optional `:port`), so this is belt-and-
139
+ // suspenders — but it is the control that catches EVERY forbidden boundary
140
+ // char (incl. `\t`, U+2028/U+2029), not just `\n`/`\r` (audit L5).
141
+ if (typeof domain !== "string" || FORBIDDEN_LINE_CHAR_RE.test(domain) || !DOMAIN_RE.test(domain)) {
142
+ throw new SignInError("invalid-params", "domain must be a single DNS host (letters, digits, '.', '-'), no control characters");
132
143
  }
133
144
  if (!decodeBase58_32(params.address)) {
134
145
  throw new SignInError("invalid-params", `"${params.address}" is not a valid X1 address`);
@@ -137,12 +148,12 @@ export function createSignInChallenge(params) {
137
148
  // the signer and the verifier both compare against.
138
149
  const address = encodeBase58(decodeBase58_32(params.address));
139
150
  const statement = params.statement ?? null;
140
- if (statement !== null && (statement.includes("\n") || statement.includes("\r") || statement.length === 0)) {
141
- throw new SignInError("invalid-params", "statement must be a single non-empty line");
151
+ if (statement !== null && (FORBIDDEN_LINE_CHAR_RE.test(statement) || statement.length === 0)) {
152
+ throw new SignInError("invalid-params", "statement must be a single non-empty line (no control characters)");
142
153
  }
143
154
  const uri = params.uri ?? `https://${domain}`;
144
- if (uri.includes("\n") || uri.includes("\r")) {
145
- throw new SignInError("invalid-params", "uri must be a single line");
155
+ if (FORBIDDEN_LINE_CHAR_RE.test(uri)) {
156
+ throw new SignInError("invalid-params", "uri must be a single line (no control characters)");
146
157
  }
147
158
  const nonce = params.nonce ?? generateSignInNonce();
148
159
  if (!NONCE_RE.test(nonce)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Resolve @handles and X1NS names on X1. Never silently picks between namespaces.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",