# New submissions enter moderation queue and are not visible until approved

- **Pattern:** `ab-001077` (`directory-submissions-moderation.submission-form.moderation-queue`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001077
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001077)

## Why it matters

A directory without a moderation queue is an open invitation for spam, defamatory content, and phishing links — all published the instant a form is submitted. Every public query that lacks a `WHERE status = 'approved'` filter exposes every pending, rejected, and spam entry to visitors. This violates OWASP A01 (Broken Access Control) and CWE-285 (Improper Authorization): your access control policy says only approved content is public, but the data layer does not enforce it.

## Severity rationale

Critical because auto-published submissions bypass all editorial control, exposing spam and harmful content to every visitor immediately.

## Remediation

Add a `status` column defaulting to `'pending'` and filter all public queries to `status = 'approved'`. Apply the change in the schema and every listing query:

```sql
ALTER TABLE listings ADD COLUMN status VARCHAR NOT NULL DEFAULT 'pending';
CREATE INDEX idx_listings_status ON listings(status);
```

```ts
// Always filter public reads
export async function getApprovedListings() {
  return db.listings.findMany({ where: { status: 'approved' } })
}

// On submission, never set status to 'approved'
await db.listings.create({ data: { ...parsed, status: 'pending' } })
```

Admin routes that approve or reject should update `status` and record `approved_at` / `rejected_at` timestamps.

## Detection

- **ID:** `moderation-queue`
- **Severity:** `critical`
- **What to look for:** Examine the listing submission logic and database schema. Look for a `status` field (e.g., `pending`, `approved`, `rejected`). Check that submissions are created with `status = 'pending'` or similar, and that public queries for listings only return `status = 'approved'` entries. Verify that new submissions are not immediately visible on the site.
- **Pass criteria:** Enumerate all relevant code paths. All new submissions have a `status` field that defaults to `pending` or similar. Public listing pages and searches filter to show only `approved` listings. Moderators have a separate interface to view and approve pending submissions. with at least 1 verified instance.
- **Fail criteria:** Submissions are auto-published (status field missing or always set to approved), or new listings appear publicly before any review. A partial implementation does not count as pass.
- **Skip (N/A) when:** The project has no moderation feature (e.g., all submissions are automatically published and this is acceptable).
- **Detail on fail:** `"All listings are published immediately on submission. No moderation queue. Spam entries appear on the site instantly."` or `"Status field exists but is not used to filter public listings — pending and approved listings both show publicly."`
- **Cross-reference:** Compare with `directory-submissions-moderation.moderation.moderation-actions-logged` — the queue surfaces items for review (this check); action logging records what moderators did.
- **Remediation:** Implement a moderation queue:

  ```sql
  -- Database schema
  CREATE TABLE listings (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    description TEXT NOT NULL,
    contact_email VARCHAR(255) NOT NULL,
    status VARCHAR DEFAULT 'pending' NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    approved_at TIMESTAMP,
    rejected_at TIMESTAMP,
    rejection_reason TEXT
  );

  CREATE INDEX idx_listings_status ON listings(status);
  ```

  ```ts
  // API: Only fetch approved listings
  export async function getApprovedListings() {
    return db.listings.findMany({
      where: { status: 'approved' },
      orderBy: { created_at: 'desc' }
    })
  }

  // Admin route: Approve or reject
  export async function POST(req: Request) {
    const { listingId, action, rejectionReason } = await req.json()

    if (action === 'approve') {
      await db.listings.update({
        where: { id: listingId },
        data: { status: 'approved', approved_at: new Date() }
      })
    } else if (action === 'reject') {
      await db.listings.update({
        where: { id: listingId },
        data: {
          status: 'rejected',
          rejected_at: new Date(),
          rejection_reason: rejectionReason
        }
      })
    }
  }
  ```

## External references

- cwe CWE-285
- owasp A01

Taxons: content-integrity, access-control

HTML version: https://auditbuffet.com/patterns/ab-001077
