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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions middleware/src/plugins/installService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,11 @@ async function validateValues(
// The manifest's own hint when it declared one, otherwise the
// pre-existing generic message (kept byte-identical so existing
// install-flow assertions and operator muscle memory still hold).
//
// The hint is ENGLISH — this process has no request locale. The
// wizard's `FieldRow` re-resolves it from `field.pattern_hint` in the
// active locale, keyed on this entry's `code`, so a German operator
// reads German. See `setupFieldPattern.ts` → `PatternViolation.hint`.
message:
violation.hint ??
`"${field.label}" entspricht nicht dem erwarteten Muster.`,
Expand Down
85 changes: 72 additions & 13 deletions middleware/src/plugins/setupFieldPattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@
* (b) ALLOWLIST GRAMMAR — {@link screenPatternSource} parses the pattern
* source and accepts only shapes that cannot blow up: no backreferences,
* no quantifier applied to a group that contains alternation or another
* quantifier, no quantified lookaround, bounded group nesting, and no
* open-ended or huge counted repetition. Applied at manifest LOAD time.
* quantifier, no quantified lookaround, bounded group nesting, and a cap
* on how large a counted repetition may be. Applied at manifest LOAD
* time. Counted repetition (`{n}` / `{n,}` / `{n,m}`) is governed by
* exactly the same rules as `*` and `+` — see {@link checkCountedBounds}.
*
* (b) is deliberately conservative and WILL reject legitimate-looking patterns
* (`^[a-z]+(-[a-z]+)*$` is a real catastrophic-backtracking shape even though a
Expand Down Expand Up @@ -90,7 +92,23 @@ export const PATTERN_MATCH_BUDGET_MS = 50;
/** Deepest group nesting the allowlist accepts (root counts as depth 0). */
const MAX_GROUP_DEPTH = 2;

/** Largest explicit repetition count the allowlist accepts. */
/**
* Largest explicit repetition count the allowlist accepts, applied to BOTH
* bounds of a counted quantifier (`{n}`, `{n,}`, `{n,m}`).
*
* This is defence in depth, not the safety floor. Measured on node 22:
* `^[a-z]{1,100000}[a-z]{1,100000}$` against an 8191-char non-matching subject
* takes 71 ms, versus 39 ms for `^[a-z]+[a-z]+$` — the same polynomial class as
* the `+` form the allowlist has always accepted, not a new one. (V8 compiles
* counted repetition with a counter rather than unrolling it, so a huge bound
* is not a compile-time blowup either: `^a{100000,}$` compiles AND matches a
* 100k subject in 0.43 ms.) The load-bearing bound is the 50 ms worker budget.
*
* The cap is kept because it is free and it keeps an untrusted manifest from
* naming an arbitrary number, and it is kept at 100 rather than raised because
* 100 covers every credential shape this feature exists for: DNS label ≤ 63,
* TLD 2-63, SHA-256 hex 64, UUID segments, PIN/OTP lengths.
*/
const MAX_COUNTED_REPETITION = 100;

/** How long to wait for a freshly spawned worker to come online before giving
Expand All @@ -106,6 +124,8 @@ interface QuantifierToken {
readonly length: number;
/** True for `{n}` / `{n,}` / `{n,m}` — the counted forms. */
readonly counted: boolean;
/** Lower bound for a counted form. */
readonly min?: number;
/** Upper bound for a counted form; `undefined` means open-ended (`{n,}`). */
readonly max?: number;
}
Expand Down Expand Up @@ -133,15 +153,26 @@ function parseQuantifier(src: string, i: number): QuantifierToken | null {
? undefined
: Number(maxRaw);
return max === undefined
? { length, counted: true }
: { length, counted: true, max };
? { length, counted: true, min }
: { length, counted: true, min, max };
}

/**
* Size check for a counted quantifier. SHAPE is not this function's business:
* `{n,}` is exactly `{1,}`-style open-ended repetition, i.e. the same thing `+`
* and `*` express, and it is screened by the same group-content rules those go
* through (see the `)` branch of {@link screenPatternSource}). Refusing `{n,}`
* while accepting `+` bought no safety at all — it only forced manifest authors
* to write `[A-Za-z][A-Za-z]+` where they meant `[A-Za-z]{2,}`, which is the
* identical language spelled worse. All that is left here is the numeric cap.
*
* Both bounds are capped. For `{n,}` the only number an author supplies is the
* MINIMUM, so leaving `min` unchecked would have handed an untrusted manifest
* an unbounded knob the moment `{n,}` became legal.
*/
function checkCountedBounds(q: QuantifierToken): string | null {
if (q.max === undefined) {
return 'open-ended counted repetition `{n,}` is not allowed';
}
if (q.max > MAX_COUNTED_REPETITION) {
const largest = Math.max(q.min ?? 0, q.max ?? 0);
if (largest > MAX_COUNTED_REPETITION) {
return `counted repetition above ${MAX_COUNTED_REPETITION} is not allowed`;
}
return null;
Expand All @@ -166,10 +197,17 @@ interface GroupFrame {
* - lookaround containing a quantifier — same blowup, hidden behind `(?=)`
* - group nesting > 2 — bounds what the two rules above
* have to reason about
* - `{n,}` / `{n,m}` with a huge m — bounded but arbitrarily large work
* - a counted repetition above 100 — see {@link MAX_COUNTED_REPETITION}
*
* Alternation and quantifiers are PROPAGATED to the enclosing frame on close,
* so wrapping a hostile shape in another group cannot launder it.
*
* The rules deliberately do NOT distinguish quantifier SPELLINGS. `+`, `*`,
* `{2,}` and `{2,63}` are all "a quantifier": each is refused on a group that
* contains alternation or another quantifier, and each is accepted on a simple
* atom or character class. An earlier revision refused `{n,}` outright while
* accepting `+` — logically the same construct — which bought no safety and
* made `^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$` unwritable.
*/
export function screenPatternSource(source: string): string | null {
const stack: GroupFrame[] = [
Expand Down Expand Up @@ -599,12 +637,28 @@ export interface PatternViolation {
/** The setup-field key that failed. */
field: string;
/**
* The manifest's own explanation of the expected shape, when it declared one.
* The manifest's own explanation of the expected shape, when it declared one,
* resolved to ENGLISH.
*
* DELIBERATELY OPTIONAL and never server-generated: the web-ui owns all
* user-facing copy (`messages/{en,de}.json`) and renders its own localized
* fallback when this is absent. A generated English or German sentence here
* would be an untranslatable string smuggled in through the API.
*
* WHY ENGLISH, ALWAYS — and why that is not a localization bug. The middleware
* has no notion of a request locale: nothing reads `Accept-Language`, no
* locale cookie reaches it, and the web-ui's `NEXT_LOCALE` never leaves the
* Next.js layer. Manufacturing one just for this field would be the same
* "untranslatable string smuggled in through the API" mistake in a different
* costume — the server would be picking a language for a client it cannot see.
*
* So this stays the documented fallback for API clients that have no manifest
* of their own (curl, the install CLI, third-party integrations). Anything
* that HOLDS the manifest — i.e. the web-ui, which renders
* `field.pattern_hint` next to the input already — must resolve the localized
* map itself, keyed on {@link PatternViolation.field}, and use this only when
* the key matches no field it knows about. See
* `web-ui/app/_lib/setupFieldPattern.ts` → `resolveSetupFieldHint`.
*/
hint?: string;
}
Expand All @@ -613,6 +667,10 @@ export interface PatternViolation {
* Pick the best hint string out of a `{ locale: text }` map. Mirrors the
* web-ui's `pickLocalized`: preferred locale, then `en`, then `de`, then
* anything. Kept local so this module stays dependency-free.
*
* `locale` exists for callers that genuinely have one. The middleware does not
* (see {@link PatternViolation.hint}), so every production call resolves to
* English by default and the CLIENT does the localized pick.
*/
export function pickPatternHint(
map: Record<string, string> | undefined,
Expand Down Expand Up @@ -648,12 +706,13 @@ export async function checkSetupFieldPattern(
field: PatternCheckableField,
value: string,
context = field.key,
locale = 'en',
): Promise<PatternViolation | null> {
if (!field.pattern) return null;
if (value.length === 0) return null;

const hint = pickPatternHint(field.pattern_hint, locale);
// English on purpose, and no `locale` parameter to imply otherwise: there is
// no request locale on this side of the wire. See `PatternViolation.hint`.
const hint = pickPatternHint(field.pattern_hint);
const violation: PatternViolation = hint
? { field: field.key, hint }
: { field: field.key };
Expand Down
10 changes: 7 additions & 3 deletions middleware/src/routes/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,9 +501,13 @@ export function createRuntimeRouter(deps: RuntimeDeps): Router {
code: 'runtime.setup_field_invalid',
message: `value for '${violation.field}' does not match the expected format`,
field: violation.field,
// Only ever the manifest's own localized hint. When the manifest
// declared none, `hint` is absent and the UI renders its own
// localized copy — the API never invents user-facing prose.
// Only ever the manifest's own hint, resolved to ENGLISH — this
// process has no request locale, and guessing one would smuggle an
// untranslatable string through the API. It is the fallback for
// clients without a manifest; the web-ui resolves `pattern_hint`
// itself from `field`. When the manifest declared no hint, this is
// absent and the UI renders its own localized copy.
// See `setupFieldPattern.ts` → `PatternViolation.hint`.
...(violation.hint !== undefined ? { hint: violation.hint } : {}),
});
return;
Expand Down
183 changes: 179 additions & 4 deletions middleware/test/setupFieldPatternValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,27 @@ describe('OM-17 — compileSetupPattern safety screen', () => {
assert.equal(violation?.field, 'k');
});

it('`hint` is the ENGLISH entry, and that is the documented contract', async () => {
// The middleware has no request locale — nothing reads Accept-Language and
// `NEXT_LOCALE` never leaves the Next.js layer — so it must not pretend to
// pick one. English is the fallback for API clients with no manifest; a
// client that HOLDS the manifest resolves `pattern_hint` itself, keyed on
// `violation.field`. Pinned so nobody "fixes" this into a guessed locale.
const violation = await checkSetupFieldPattern(
{
key: 'gw_sa_client_email',
pattern: SA_EMAIL_PATTERN,
pattern_hint: {
en: 'expects …@….iam.gserviceaccount.com',
de: 'erwartet …@….iam.gserviceaccount.com',
},
},
'tester@customer-company.de',
);
assert.equal(violation?.field, 'gw_sa_client_email');
assert.equal(violation?.hint, 'expects …@….iam.gserviceaccount.com');
});

it('omits `hint` when the manifest declared no pattern_hint', async () => {
// The API must never invent user-facing prose; the web-ui owns that copy.
const violation = await checkSetupFieldPattern(
Expand Down Expand Up @@ -359,8 +380,22 @@ const REDOS_ALREADY_BLOCKED = [
'^(a{1,10}){1,10}b$',
];

/** The two shapes this whole feature exists for. These MUST keep working. */
const REALISTIC_PATTERNS = [SA_EMAIL_PATTERN, '^-----BEGIN [A-Z ]*PRIVATE KEY-----'];
/**
* Every pattern in the FIRST real manifest written against this feature
* (byte5ai/omadia-google-workspace#1). These MUST keep working — the feature is
* worthless if the manifest it exists for cannot express what it needs.
*
* `^…\.[A-Za-z]{2,}$` is here because the allowlist used to refuse `{n,}` while
* accepting `+`, which is the same construct. The manifest author had to write
* `[A-Za-z][A-Za-z]+` — identical language, worse to read — to get it past the
* screen. See {@link screenPatternSource}.
*/
const REALISTIC_PATTERNS = [
SA_EMAIL_PATTERN,
'^-----BEGIN [A-Z ]*PRIVATE KEY-----',
'^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$',
'^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,63}$',
];

describe('OM-17 / F1 — allowlist grammar replaces the bypassable blacklist', () => {
beforeEach(() => {
Expand Down Expand Up @@ -401,9 +436,8 @@ describe('OM-17 / F1 — allowlist grammar replaces the bypassable blacklist', (
assert.notEqual(screenPatternSource('^(?=.*a+)b$'), null);
});

it('rejects group nesting deeper than 2 and open-ended/large {n,m}', () => {
it('rejects group nesting deeper than 2 and oversized {n,m}', () => {
assert.notEqual(screenPatternSource('^(((a)))b$'), null);
assert.notEqual(screenPatternSource('a{2,}'), null);
assert.notEqual(screenPatternSource('a{1,5000}'), null);
assert.equal(screenPatternSource('^\\d{3}-\\d{4}$'), null);
});
Expand All @@ -413,6 +447,147 @@ describe('OM-17 / F1 — allowlist grammar replaces the bypassable blacklist', (
});
});

// ---------------------------------------------------------------------------
// F5 — the allowlist refused `{n,}` while accepting `+`, which IS `{1,}`
// ---------------------------------------------------------------------------

/**
* The rule bought no safety and only cost manifest authors: the very first
* realistic pattern written against this feature — an email TLD,
* `^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$` — was refused and had to ship as
* `[A-Za-z][A-Za-z]+`, which is the identical language spelled worse.
*
* Counted quantifiers are now screened by exactly the rules `*` and `+` go
* through: refused on a group containing alternation or another quantifier,
* accepted on a simple atom or character class, with a numeric cap on both
* bounds. The `REDOS_BYPASSES` table above is the other half of this change —
* every hostile shape there must still be rejected, and each of those shapes
* would also be rejected written as `{n,}` (see below).
*/
describe('OM-17 / F5 — `{n,}` is screened exactly like the `+` it is equal to', () => {
beforeEach(() => {
resetSetupPatternCache();
});

const ACCEPTED: ReadonlyArray<readonly [string, string]> = [
['^[A-Za-z]{2,}$', 'open-ended counted repetition on a character class'],
['^a{2,}$', 'open-ended counted repetition on a literal'],
['^[A-Za-z]{2,63}$', 'the bounded form of the same thing'],
['^\\d{4}$', 'an exact count'],
['^[a-z]{0,}$', '`{0,}` — i.e. `*`'],
['^[a-z]{2,}?$', 'the lazy form'],
['^a{100}$', 'exactly at the counted-repetition cap'],
['^a{100,}$', 'the cap applied to the MINIMUM of an open-ended form'],
];

for (const [pattern, why] of ACCEPTED) {
it(`accepts ${pattern} (${why})`, () => {
assert.equal(
screenPatternSource(pattern),
null,
`${pattern} must be accepted — it is exactly what \`+\`/\`*\` express`,
);
assert.ok(compileSetupPattern(pattern, 'test') instanceof RegExp);
});
}

const REJECTED: ReadonlyArray<readonly [string, string]> = [
['^a{101}$', 'one above the counted-repetition cap'],
['^a{101,}$', 'the MINIMUM of an open-ended form is capped too — without '
+ 'that, allowing `{n,}` would hand a manifest an unbounded knob'],
['^a{1,101}$', 'upper bound above the cap'],
['^a{100000,}$', 'an absurd open-ended minimum'],
// The hostile shapes from REDOS_BYPASSES, rewritten with `{n,}`. Allowing
// the counted spelling must not open a door the `+` spelling keeps shut.
['^(a|a){1,}$', '`^(a|a)+$` in counted clothing — quantified alternation'],
['^(a{1,})+$', '`^(a+)+$` in counted clothing — nested quantifier'],
['^(a{1,}){1,}$', 'both halves counted'],
['^((a{2,})){2,}$', '`^((a+))+$` in counted clothing — laundering by nesting'],
['^(?:a|a){2,}$', 'non-capturing group does not launder it either'],
// No `.*` here on purpose — the counted quantifier must be the ONLY thing
// that trips the lookaround rule, otherwise the case proves nothing.
['^(?=a{1,})b$', 'lookaround containing an open-ended counted repetition'],
];

for (const [pattern, why] of REJECTED) {
it(`still rejects ${pattern} (${why})`, () => {
assert.notEqual(
screenPatternSource(pattern),
null,
`${pattern} was accepted by the screen`,
);
assert.equal(compileSetupPattern(pattern, 'test'), null);
});
}

it('an accepted `{n,}` pattern MATCHES correctly end to end', async () => {
// Compiling is not the bar — the pattern has to do its job. This is the
// literal OM-17 confusion, on the field the real manifest declares with
// `{2,}`: a plausible-looking address must pass and a password must not.
const field = {
key: 'gw_impersonated_user',
pattern: '^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$',
};
assert.equal(
await checkSetupFieldPattern(field, 'tester@customer-company.de'),
null,
);
assert.equal(await checkSetupFieldPattern(field, 'admin@byte5.io'), null);
// `{2,}` really is open-ended: a long TLD must pass, where `{2}` would not.
assert.equal(
await checkSetupFieldPattern(field, 'ops@example.technology'),
null,
);
// …and it really is a MINIMUM of two: a 1-char TLD must fail.
assert.equal(
(await checkSetupFieldPattern(field, 'ops@example.x'))?.field,
'gw_impersonated_user',
);
// What the tester actually typed into a field like this.
assert.equal(
(await checkSetupFieldPattern(field, 'hunter2'))?.field,
'gw_impersonated_user',
);
});

it('the bounded `{2,63}` form matches the same way, and enforces its cap', async () => {
const field = { key: 'email', pattern: '^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,63}$' };
assert.equal(await checkSetupFieldPattern(field, 'a@b.de'), null);
assert.equal(
(await checkSetupFieldPattern(field, `a@b.${'x'.repeat(64)}`))?.field,
'email',
);
});

it('a whole realistic manifest field set loads with every pattern intact', async () => {
// Mirrors the field set of the first real manifest written against this
// feature. Inlined rather than read from that repo: the assertion is about
// OUR screen, and a test must not depend on a sibling checkout existing.
const plugin = adaptManifestV1({
schema_version: '1',
identity: { id: 'gw', name: 'Google Workspace', version: '1.0.0' },
setup: {
fields: REALISTIC_PATTERNS.map((pattern, idx) => ({
key: `f${String(idx)}`,
type: 'secret',
pattern,
pattern_hint: { en: 'en hint', de: 'de hint' },
})),
},
});
assert.equal(plugin?.setup_fields.length, REALISTIC_PATTERNS.length);
for (const f of plugin?.setup_fields ?? []) {
assert.equal(
f.pattern_unavailable,
undefined,
`${String(f.pattern)} came back pattern_unavailable`,
);
assert.ok(f.pattern, `${f.key} lost its pattern`);
}
assert.deepEqual(getPatternProblems(), []);
});
});

describe('OM-17 / F1 — hard execution bound on the match itself', () => {
after(async () => {
await shutdownPatternWorker();
Expand Down
Loading
Loading