From a71e6931b1843af071580e26a7d4afab6417c93e Mon Sep 17 00:00:00 2001 From: awais786 Date: Sat, 16 May 2026 15:21:10 +0500 Subject: [PATCH 1/7] chore(ci): add fork-side SSO audit script + workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-fork deterministic audit for the cross-app SSO contract. Mirrors Pressingly/plane#32 and Pressingly/twenty#9. Rows covered: 14 — SPA logout (app/stores/AuthStore.ts) MUST NOT call /oauth2/sign_out 20 — server/middlewares/authentication.ts MUST define normalizeProxyEmail OR throw AuthenticationError on proxy-identity mismatch in the JWT cookie branch. SECURITY-CRITICAL. 21 — No polynomial-backtracking email-shape regex in authentication.ts Local dry-run on foss-main: row 14 ✅, row 20 ❌, row 21 ❌. Both correctly waiting for Pressingly/ outline#19 (introduces normalizeProxyEmail + replaces the regex with indexOf). When #19 merges, this audit will go green on foss-main. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/sso-audit.yml | 64 +++++++++++ scripts/sso-audit.sh | 189 ++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 .github/workflows/sso-audit.yml create mode 100755 scripts/sso-audit.sh diff --git a/.github/workflows/sso-audit.yml b/.github/workflows/sso-audit.yml new file mode 100644 index 000000000000..00bef83da928 --- /dev/null +++ b/.github/workflows/sso-audit.yml @@ -0,0 +1,64 @@ +name: SSO fork audit + +# Runs scripts/sso-audit.sh against this fork's auth code to verify +# satisfaction of the cross-app SSO contract at +# awais786/sso-rules-moneta:openspec/specs/proxy-auth-middleware/spec.md. +# +# Covers fork-side rows 14, 20, 21 from SKILL.md §5. Exits 1 on security- +# critical violations (row 20 — session-identity reconciliation). + +on: + pull_request: + paths: + - 'server/middlewares/authentication.ts' + - 'server/middlewares/authentication.test.ts' + - 'app/stores/AuthStore.ts' + - 'scripts/sso-audit.sh' + - '.github/workflows/sso-audit.yml' + push: + branches: [foss-main] + schedule: + - cron: '0 9 * * 1' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + sso-fork-audit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run fork audit + id: audit + run: | + set -o pipefail + if bash scripts/sso-audit.sh | tee audit-output.md; then + echo "audit_exit=0" >> "$GITHUB_OUTPUT" + else + echo "audit_exit=$?" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to job summary + if: always() + run: cat audit-output.md >> "$GITHUB_STEP_SUMMARY" + + - name: Post sticky PR comment + if: | + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + always() + continue-on-error: true + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: sso-fork-audit + path: audit-output.md + + - name: Fail on security-critical violations + if: steps.audit.outputs.audit_exit != '0' + run: | + echo "::error::Security-critical SSO contract violation in fork audit. See table for the failing row and fix." + exit 1 diff --git a/scripts/sso-audit.sh b/scripts/sso-audit.sh new file mode 100755 index 000000000000..01c04fee98ed --- /dev/null +++ b/scripts/sso-audit.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# +# sso-audit.sh — Outline-side fork audit against the cross-app SSO contract. +# +# ============================================================================ +# Covers fork-side rows of awais786/sso-rules-moneta:openspec/specs/ +# proxy-auth-middleware/spec.md that a deterministic bash check can verify +# against Outline's source tree. Catches regressions BEFORE they reach +# foss-server-bundle-devstack. +# +# Exit codes: +# 0 — all rows ✅ or n/a / informational +# 1 — at least one SECURITY-CRITICAL row failed +# +# Rows covered: +# Row 14 — logout shape: SPA logout (app/stores/AuthStore.ts) MUST NOT +# call /oauth2/sign_out. +# Row 20 — session-identity reconciliation (Rule 2 mismatch flush): +# server/middlewares/authentication.ts MUST throw +# AuthenticationError on identity mismatch when AUTH_TYPE === 'SSO' +# and transport === 'cookie'. The existing outer auth() catch +# block clears the accessToken cookie via err.headers. +# SECURITY-CRITICAL. +# Row 21 — email-shape detection MUST NOT use polynomial-backtracking +# regex. Outline uses indexOf-based detection in +# normalizeProxyEmail (post-PR #19); this row is a regression +# guard. +# ============================================================================ + +set -euo pipefail + +AUTH_MIDDLEWARE="server/middlewares/authentication.ts" +AUTH_STORE="app/stores/AuthStore.ts" + +declare -a ROW_STATUS=() +declare -a ROW_TITLES=( + "logout shape: SPA logout does not call /oauth2/sign_out" + "session-identity reconciliation present (Rule 2 mismatch flush)" + "email-shape detection uses indexOf, not polynomial regex" +) +declare -a ROW_NOTES=() +declare -a ROW_NUMBERS=(14 20 21) + +SECURITY_CRITICAL=(1) +SECURITY_CRITICAL_FAILS=0 + +record() { + local idx=$1 status=$2 note=$3 + ROW_STATUS[$idx]="$status" + ROW_NOTES[$idx]="$note" + if [[ "$status" == "❌" ]]; then + for c in "${SECURITY_CRITICAL[@]}"; do + if [[ "$c" -eq "$idx" ]]; then + SECURITY_CRITICAL_FAILS=$((SECURITY_CRITICAL_FAILS + 1)) + return + fi + done + fi +} + +# ============================================================================ +# Row 14 (idx 0): logout shape — narrow check +# ============================================================================ +check_row_14() { + if [[ ! -f "$AUTH_STORE" ]]; then + record 0 "?" "$AUTH_STORE not found — skipping" + return + fi + + if grep -qE '/oauth2/sign_?out' "$AUTH_STORE"; then + local line + line=$(grep -nE '/oauth2/sign_?out' "$AUTH_STORE" | head -1) + record 0 "❌" "Logout calls \`/oauth2/sign_out\` at $AUTH_STORE:$line — per logout-flow spec, per-app Logout is navigation-only. Drop the call; portal 'Logout all' handles oauth2-proxy clearing." + return + fi + + record 0 "✅" "$AUTH_STORE does not invoke \`/oauth2/sign_out\` (this row verifies only that the SPA doesn't try to clear the upstream proxy cookie itself; that's the portal's job)" +} + +# ============================================================================ +# Row 20 (idx 1): session-identity reconciliation +# +# Outline's flush mechanism is: throw AuthenticationError → outer auth() +# catch block clears the accessToken cookie via err.headers. The deterministic +# signal is the presence of the `normalizeProxyEmail` helper function — it +# was added in Pressingly/outline#19 specifically to do the bidirectional +# header normalisation for the mismatch comparison. The function only exists +# post-fix, so its presence is a precise marker. +# +# A weaker fallback check looks for the "proxy identity" literal in a +# `throw AuthenticationError(...)` call, in case a future refactor renames +# the helper. +# +# The test suite at server/middlewares/authentication.test.ts pins the +# exact behaviour with the `should clear stale accessToken cookie when +# proxy identity changes` and `should NOT clear cookie when proxy email +# matches JWT user` test cases. +# +# SECURITY-CRITICAL: without this, the stale-session leak returns. +# ============================================================================ +check_row_20() { + if [[ ! -f "$AUTH_MIDDLEWARE" ]]; then + record 1 "?" "$AUTH_MIDDLEWARE not found — skipping" + return + fi + + # Primary marker: normalizeProxyEmail function (introduced by PR #19). + local has_normalize_helper + has_normalize_helper=$(grep -cE "\bnormalizeProxyEmail\b" "$AUTH_MIDDLEWARE" || true) + + # Fallback marker: "proxy identity" literal in a throw — used in the + # AuthenticationError message when the mismatch fires. + local has_throw_proxy + has_throw_proxy=$(grep -cE "throw\s+AuthenticationError\(.*[Pp]roxy" "$AUTH_MIDDLEWARE" || true) + + if [[ "$has_normalize_helper" -gt 0 ]]; then + record 1 "✅" "$AUTH_MIDDLEWARE defines \`normalizeProxyEmail\` and uses it for bidirectional header normalisation — Rule 2 mismatch flush in place (flush mechanism: throw 401 → outer auth() catch clears accessToken cookie)" + return + fi + + if [[ "$has_throw_proxy" -gt 0 ]]; then + record 1 "✅" "$AUTH_MIDDLEWARE has \`throw AuthenticationError(...proxy...)\` indicating a stale-session detection — Rule 2 mismatch flush in place (consider extracting bidirectional normalisation into a normalizeProxyEmail helper for clarity)" + return + fi + + record 1 "❌" "$AUTH_MIDDLEWARE does NOT define \`normalizeProxyEmail\` and has no \`throw AuthenticationError(...proxy...)\` call site. The cross-app spec (proxy-auth-middleware Rule 2) requires the JWT cookie branch of validateAuthentication to compare normalized X-Auth-Request-Email against the JWT user's email and throw AuthenticationError on mismatch, gated on \`env.AUTH_TYPE === 'SSO'\` and \`transport === 'cookie'\`. The existing outer auth() catch block then clears the accessToken + lastSignedIn cookies via err.headers. Without this check, the stale-session-on-user-switch leak returns. Reference: Pressingly/outline#19." +} + +# ============================================================================ +# Row 21 (idx 2): polynomial-regex avoidance in email-shape detection +# ============================================================================ +check_row_21() { + if [[ ! -f "$AUTH_MIDDLEWARE" ]]; then + record 2 "?" "$AUTH_MIDDLEWARE not found — skipping" + return + fi + + local hits + hits=$(grep -nE '\[\^[\\\\]s@\]\+@\[\^[\\\\]s@\]\+\\\.\[\^[\\\\]s@\]\+' "$AUTH_MIDDLEWARE" 2>/dev/null || true) + + if [[ -n "$hits" ]]; then + record 2 "❌" "Polynomial-backtracking email-shape regex detected in $AUTH_MIDDLEWARE: $hits. Rewrite to indexOf-based check per openspec proxy-auth-middleware §'email-shape detection SHALL avoid polynomial-backtracking regex'. Reference: \`normalizeProxyEmail\` in this file." + return + fi + + record 2 "✅" "No polynomial-backtracking email-shape regex in $AUTH_MIDDLEWARE; using indexOf-based detection" +} + +# ============================================================================ +# Run checks +# ============================================================================ +check_row_14 +check_row_20 +check_row_21 + +# ============================================================================ +# Print table +# ============================================================================ +echo "## Outline SSO Fork Audit" +echo +echo "Cross-app contract: https://github.com/awais786/sso-rules-moneta/blob/main/openspec/specs/proxy-auth-middleware/spec.md" +echo "Row numbers match the 21-row table at https://github.com/awais786/sso-rules-moneta/blob/main/skills/app-rules/SKILL.md#5-report" +echo +echo "| Row | Invariant | Status | Notes |" +echo "|-----|-----------|--------|-------|" +for i in "${!ROW_TITLES[@]}"; do + printf "| %d | %s | %s | %s |\n" \ + "${ROW_NUMBERS[$i]}" "${ROW_TITLES[$i]}" "${ROW_STATUS[$i]:-?}" "${ROW_NOTES[$i]:-}" +done +echo + +# ============================================================================ +# Summary + exit code +# ============================================================================ +TOTAL_FAILS=0 +for s in "${ROW_STATUS[@]}"; do + [[ "$s" == "❌" ]] && TOTAL_FAILS=$((TOTAL_FAILS + 1)) +done + +if [[ "$TOTAL_FAILS" -eq 0 ]]; then + echo "**All fork-side invariants hold.**" + exit 0 +fi + +echo "**$TOTAL_FAILS violations.** Security-critical (row 20): $SECURITY_CRITICAL_FAILS." +if [[ "$SECURITY_CRITICAL_FAILS" -gt 0 ]]; then + exit 1 +fi +exit 0 From 9596c2bf70ecb6c2055f1bce4e270a52eaf28f00 Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Sat, 16 May 2026 16:36:48 +0500 Subject: [PATCH 2/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/sso-audit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sso-audit.yml b/.github/workflows/sso-audit.yml index 00bef83da928..02af1b9b6826 100644 --- a/.github/workflows/sso-audit.yml +++ b/.github/workflows/sso-audit.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Run fork audit id: audit From c2b3e7c9e7b9a80e5da53676c20efe985a8b3f1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 11:39:23 +0000 Subject: [PATCH 3/7] chore(ci): scope sso audit PR comment permissions Agent-Logs-Url: https://github.com/Pressingly/outline/sessions/f90ab832-2130-4fdd-b46b-9f568cb14996 Co-authored-by: awais786 <445320+awais786@users.noreply.github.com> --- .github/workflows/sso-audit.yml | 39 +++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sso-audit.yml b/.github/workflows/sso-audit.yml index 02af1b9b6826..164df05af474 100644 --- a/.github/workflows/sso-audit.yml +++ b/.github/workflows/sso-audit.yml @@ -23,10 +23,11 @@ on: permissions: contents: read - pull-requests: write jobs: sso-fork-audit: + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Checkout @@ -46,15 +47,11 @@ jobs: if: always() run: cat audit-output.md >> "$GITHUB_STEP_SUMMARY" - - name: Post sticky PR comment - if: | - github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository && - always() - continue-on-error: true - uses: marocchino/sticky-pull-request-comment@v2 + - name: Upload audit output + if: always() + uses: actions/upload-artifact@v4 with: - header: sso-fork-audit + name: sso-audit-output path: audit-output.md - name: Fail on security-critical violations @@ -62,3 +59,27 @@ jobs: run: | echo "::error::Security-critical SSO contract violation in fork audit. See table for the failing row and fix." exit 1 + + sso-fork-audit-comment: + needs: sso-fork-audit + if: | + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Download audit output + uses: actions/download-artifact@v4 + with: + name: sso-audit-output + path: sso-audit-output + + - name: Post sticky PR comment + continue-on-error: true + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: sso-fork-audit + path: sso-audit-output/audit-output.md From 6b26a1cd2ce771171ee98488c6b35fca12dbe750 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 11:40:09 +0000 Subject: [PATCH 4/7] chore(ci): simplify audit artifact download path Agent-Logs-Url: https://github.com/Pressingly/outline/sessions/f90ab832-2130-4fdd-b46b-9f568cb14996 Co-authored-by: awais786 <445320+awais786@users.noreply.github.com> --- .github/workflows/sso-audit.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sso-audit.yml b/.github/workflows/sso-audit.yml index 164df05af474..2105e54e28e0 100644 --- a/.github/workflows/sso-audit.yml +++ b/.github/workflows/sso-audit.yml @@ -75,11 +75,11 @@ jobs: uses: actions/download-artifact@v4 with: name: sso-audit-output - path: sso-audit-output + path: . - name: Post sticky PR comment continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 with: header: sso-fork-audit - path: sso-audit-output/audit-output.md + path: audit-output.md From c7ce838267a9ec7ab301a8fb98809613be23dfa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 11:43:20 +0000 Subject: [PATCH 5/7] fix(audit): use POSIX-compliant grep patterns Agent-Logs-Url: https://github.com/Pressingly/outline/sessions/c10e5d62-8adf-4de2-9854-c2e176770a90 Co-authored-by: awais786 <445320+awais786@users.noreply.github.com> --- scripts/sso-audit.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/sso-audit.sh b/scripts/sso-audit.sh index 01c04fee98ed..70c2e3dc20aa 100755 --- a/scripts/sso-audit.sh +++ b/scripts/sso-audit.sh @@ -106,12 +106,12 @@ check_row_20() { # Primary marker: normalizeProxyEmail function (introduced by PR #19). local has_normalize_helper - has_normalize_helper=$(grep -cE "\bnormalizeProxyEmail\b" "$AUTH_MIDDLEWARE" || true) + has_normalize_helper=$(grep -cF "normalizeProxyEmail" "$AUTH_MIDDLEWARE" || true) # Fallback marker: "proxy identity" literal in a throw — used in the # AuthenticationError message when the mismatch fires. local has_throw_proxy - has_throw_proxy=$(grep -cE "throw\s+AuthenticationError\(.*[Pp]roxy" "$AUTH_MIDDLEWARE" || true) + has_throw_proxy=$(grep -cE "throw[[:space:]]+AuthenticationError\(.*[Pp]roxy" "$AUTH_MIDDLEWARE" || true) if [[ "$has_normalize_helper" -gt 0 ]]; then record 1 "✅" "$AUTH_MIDDLEWARE defines \`normalizeProxyEmail\` and uses it for bidirectional header normalisation — Rule 2 mismatch flush in place (flush mechanism: throw 401 → outer auth() catch clears accessToken cookie)" @@ -136,7 +136,7 @@ check_row_21() { fi local hits - hits=$(grep -nE '\[\^[\\\\]s@\]\+@\[\^[\\\\]s@\]\+\\\.\[\^[\\\\]s@\]\+' "$AUTH_MIDDLEWARE" 2>/dev/null || true) + hits=$(grep -nE '\[\^\\s@\]\+@' "$AUTH_MIDDLEWARE" 2>/dev/null | head -1 || true) if [[ -n "$hits" ]]; then record 2 "❌" "Polynomial-backtracking email-shape regex detected in $AUTH_MIDDLEWARE: $hits. Rewrite to indexOf-based check per openspec proxy-auth-middleware §'email-shape detection SHALL avoid polynomial-backtracking regex'. Reference: \`normalizeProxyEmail\` in this file." From fc31ab9760e584e26e055b95893bffc939eb51bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 11:44:19 +0000 Subject: [PATCH 6/7] docs(audit): clarify Row 21 pattern rationale Agent-Logs-Url: https://github.com/Pressingly/outline/sessions/c10e5d62-8adf-4de2-9854-c2e176770a90 Co-authored-by: awais786 <445320+awais786@users.noreply.github.com> --- scripts/sso-audit.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/sso-audit.sh b/scripts/sso-audit.sh index 70c2e3dc20aa..8f184d91c3d8 100755 --- a/scripts/sso-audit.sh +++ b/scripts/sso-audit.sh @@ -128,6 +128,13 @@ check_row_20() { # ============================================================================ # Row 21 (idx 2): polynomial-regex avoidance in email-shape detection +# +# Detects the problematic pattern [^\s@]+@ which causes polynomial +# backtracking. The pattern matches character classes that exclude both +# whitespace (\s) and @ symbols, followed by +@ — this is the exact +# construct that leads to catastrophic backtracking in email validation. +# Using head -1 ensures only the first match is reported, preventing +# newlines from breaking the markdown table output. # ============================================================================ check_row_21() { if [[ ! -f "$AUTH_MIDDLEWARE" ]]; then From ff9aeff0d2f91bbcab7e8a29e6594d2ffefcf95c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 11:45:26 +0000 Subject: [PATCH 7/7] docs(audit): clarify grep pattern searches literal source Agent-Logs-Url: https://github.com/Pressingly/outline/sessions/c10e5d62-8adf-4de2-9854-c2e176770a90 Co-authored-by: awais786 <445320+awais786@users.noreply.github.com> --- scripts/sso-audit.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/sso-audit.sh b/scripts/sso-audit.sh index 8f184d91c3d8..6ebb2cd46cc4 100755 --- a/scripts/sso-audit.sh +++ b/scripts/sso-audit.sh @@ -133,6 +133,12 @@ check_row_20() { # backtracking. The pattern matches character classes that exclude both # whitespace (\s) and @ symbols, followed by +@ — this is the exact # construct that leads to catastrophic backtracking in email validation. +# +# Note: The grep pattern '\[\^\\s@\]\+@' searches for the literal string +# "[^\s@]+@" in the source code (where \s is a JavaScript regex token, +# not a grep pattern). We're escaping the brackets and + to match them +# literally in the source. +# # Using head -1 ensures only the first match is reported, preventing # newlines from breaking the markdown table output. # ============================================================================