Fix a Datasette Permission Bypass
Expose and fix a Datasette table permission bypass on your local machine.
Introduction
30 Second Summary
A locked door feels safe until a tiny detail lets someone walk through it. Software can hide the same kind of gap behind a single extra character.
In this project, you will reproduce a real table-permission bug in Datasette using a local SQLite database. Your before-and-after test captures private rows on a vulnerable version before confirming refusal on the 0.65.5 fix.
What You'll Build
Your finished demo puts the leak and the fix side by side: one request reveals private rows before the upgrade, then the same request is refused after it.
By the end of this project, you'll have:
- A local permission demo where the public table opens while the private table refuses an ordinary request.
- A before-and-after security test showing private JSON rows on the vulnerable release. The same test gets refused on 0.65.5.
- A security note for GHSA-h547-rmjf-5m2m that records the affected range beside your installed version. Your pinned requirements file keeps future installs on 0.65.5.
- Secret Mission: An optional challenge to push your skills further.
Are there any prerequisites?
You need Python 3 with pip, curl, plus a little SQL familiarity.
Everything runs locally at no cost.
Before We Start
A reliable vulnerability test needs a controlled local workspace. That keeps the private rows in this project away from anything you care about.
You will use a terminal to run the demonstration.
Python 3 runs Datasette. pip installs the exact release needed later.
In this step, get ready to:
- Open a terminal.
- Verify Python 3.
- Prepare an empty working directory.
Check your local tools
The terminal gives you a direct way to run the same requests before the security fix and after it. Running pip through Python confirms that your package installer belongs to the interpreter you will use.
macOS
- Press Cmd+Space to open Spotlight.
- Type Terminal in the search field.
- Press Return to open Terminal.
- Check Python 3 and pip by running these commands:
python3 --version
python3 -m pip --version
What do these checks prove?
- The python3 --version command confirms that your terminal can find Python 3.
- The python3 -m pip --version command runs pip through that Python installation.
- The pip result shows its version. It also shows its installation path.
✔️ I see both version numbers
Your Python environment is ready. You can move to the workspace substep.
ⓧ Python is not found
Python must be installed before pip can manage Datasette. The official macOS installer includes the interpreter needed for this project.
The installer may ask for your Mac password. That approval lets the signed installer add Python to your computer.
- Visit the official Python downloads for macOS.
- Choose the macOS installer for the latest stable release.
- Run the downloaded .pkg file.
- Accept the default options in the installer.
- Close Terminal after the installation finishes.
- Reopen Terminal through Spotlight.
- Confirm Python 3 and pip by running these commands:
python3 --version
python3 -m pip --version
What should you see?
Each command should print a version. The pip result should also include a path connected to your Python installation.
Still missing Python?
Close every Terminal window before opening a new one. This gives your shell a fresh chance to find the installation.
Check that the installer completed without reporting a permissions problem.
Ask for help with a Python installation that Terminal cannot find.
ⓧ pip is missing
Python includes the ensurepip module for bootstrapping pip. You can use the interpreter that already passed the first check.
- Install pip through Python by running this command:
python3 -m ensurepip --upgrade
What does this command do?
The command asks Python to bootstrap pip. The upgrade option replaces an older bundled copy when needed.
- Confirm pip is available by running this command:
python3 -m pip --version
What should you see?
You should see a pip version followed by its installation path. That result confirms Python can load pip.
Still missing pip?
Reinstall Python from the official macOS installer if your Python build does not include the bootstrap module.
Ask for help with a missing pip module on macOS.
Windows
- Press the Windows key to open search.
- Type Windows PowerShell in the search field.
- Select Windows PowerShell.
- Select Open.
- Check Python 3 and pip by running these commands:
python --version
python -m pip --version
What do these checks prove?
- The python --version command confirms that PowerShell can find Python.
- The python -m pip --version command runs pip through that Python installation.
- The pip result shows its version. It also shows its installation path.
✔️ I see both version numbers
Your Python environment is ready. You can move to the workspace substep.
ⓧ Python is not found
Windows uses the Python install manager to make Python available as a terminal command. Installing it prepares the interpreter that pip uses.
Windows may ask you to approve the installation. That prompt allows the install manager to add Python to your computer.
- Visit the official Python downloads for Windows.
- Download the Python install manager.
- Run the downloaded installer.
- Approve the Windows permission prompt if it appears.
- Complete the installer using its recommended options.
- Close Windows PowerShell after the installation finishes.
- Reopen Windows PowerShell through search.
- Confirm Python 3 and pip by running these commands:
python --version
python -m pip --version
What should you see?
Each command should print a version. The pip result should also include a path connected to your Python installation.
Still missing Python?
Close every PowerShell window before opening a new one. This refreshes the commands available to the terminal.
Check that the Python install manager finished without reporting a permissions problem.
Ask for help with a Python installation that PowerShell cannot find.
ⓧ pip is missing
Python includes the ensurepip module for bootstrapping pip. You can use the interpreter that already passed the first check.
- Install pip through Python by running this command:
python -m ensurepip --upgrade
What does this command do?
The command asks Python to bootstrap pip. The upgrade option replaces an older bundled copy when needed.
- Confirm pip is available by running this command:
python -m pip --version
What should you see?
You should see a pip version followed by its installation path. That result confirms Python can load pip.
Still missing pip?
Repair the Python installation if your interpreter does not include the bootstrap module.
Ask for help with a missing pip module on Windows.
Prepare the demonstration workspace
A dedicated folder keeps the database and permission files together. Starting with an empty folder also makes every file created later easy to identify.
macOS
- Create the datasette-permission-bug folder on your Desktop by running these commands:
mkdir ~/Desktop/datasette-permission-bug
cd ~/Desktop/datasette-permission-bug
What do these commands do?
- The first command creates the datasette-permission-bug folder on your Desktop.
- The second command makes that folder your current terminal location.
Before you check, what path do you expect the terminal to print?
- Verify the folder location and its empty starting state by running these commands:
pwd
ls -a
What should you see?
The printed path should end with Desktop/datasette-permission-bug.
The listing should contain only . and ... Those entries represent the current folder and its parent.
Your clean testing ground is ready. Every project file added from here has a clear purpose.
Folder already exists?
Choose a different folder name if the existing directory contains files you want to keep. An empty workspace prevents old files from changing the demonstration.
Ask for help with preparing an empty project folder on macOS.
Windows
- Create the datasette-permission-bug folder on your Desktop by running these commands:
New-Item -ItemType Directory -Path "$HOME\Desktop\datasette-permission-bug"
Set-Location -Path "$HOME\Desktop\datasette-permission-bug"
What do these commands do?
- The first command creates the datasette-permission-bug folder on your Desktop.
- The second command makes that folder your current PowerShell location.
Before you check, what path do you expect PowerShell to print?
- Verify the folder location and its empty starting state by running these commands:
Get-Location
Get-ChildItem
What should you see?
The printed path should end with Desktop\datasette-permission-bug.
The contents check should return no entries. That confirms the folder is empty.
Your clean testing ground is ready. Every project file added from here has a clear purpose.
Folder already exists?
Choose a different folder name if the existing directory contains files you want to keep. An empty workspace prevents old files from changing the demonstration.
Ask for help with preparing an empty project folder on Windows.
Your terminal and Python tools are ready. Next up, you will create the public and private tables for the permission test.
Create the Test Database
To reproduce the permission bug later, you need a controlled baseline. It needs visible data beside rows intended for protection.
You will deliberately install Datasette 0.65.4 for the pre-fix behavior. The sample SQLite database stays on your machine with invented rows.
In this step, get ready to:
- Prepare Datasette 0.65.4 for the demonstration.
- Create both sample tables in private_rows.db.
- Serve private_rows.db locally to confirm its table list.
Install the vulnerable Datasette release
A version pin asks pip for one exact release. Version 0.65.4 gives you the pre-fix behavior needed for this demonstration.
- Check whether Datasette is already available by running this command:
datasette --version
What does this check?
The version check reports which Datasette release your terminal can run. It also reveals when the command is unavailable.
✔️ I see the pinned release
The output contains 0.65.4. The required release is ready for the database setup.
ⓧ I see another version
A different release would change the behavior of this security demonstration. Installing the exact pin replaces that release for this environment.
- Install the pinned release by running this command:
pip install datasette==0.65.4
What does this install?
The package specification selects Datasette 0.65.4 exactly. This preserves the vulnerable behavior you will test later.
ⓧ Command not found
Your terminal cannot currently find Datasette. The pinned installation adds its command to the Python environment used by pip.
- Install the pinned release by running this command:
pip install datasette==0.65.4
What does this install?
The command installs Datasette 0.65.4 with the dependencies needed to run its local interface.
- Confirm the installed release by running the version check again:
datasette --version
Why verify again?
This second check confirms that your terminal resolves to the pinned installation. The output must contain 0.65.4 before you continue.
You should now see 0.65.4 in the command output.
Still seeing the wrong release?
- Check that the installation command ends with datasette==0.65.4.
- Restart your terminal if it still resolves to an older installation path.
Help me fix the Datasette version.
Create the two-table database
Both tables use the same two-column shape. Their different row text makes an accidental disclosure easy to recognize.
- Create private_rows.db in the working directory from earlier by running this command:
python -c "import sqlite3
# Create the database file in the current directory.
connection = sqlite3.connect('private_rows.db')
# Give both tables the same simple two-column shape.
connection.execute('CREATE TABLE public (id INTEGER PRIMARY KEY, message TEXT)')
connection.execute('CREATE TABLE private (id INTEGER PRIMARY KEY, message TEXT)')
# Add safe public examples and invented private examples.
connection.executemany('INSERT INTO public VALUES (?, ?)', [(1, 'This row is public'), (2, 'Anyone can read this')])
connection.executemany('INSERT INTO private VALUES (?, ?)', [(1, 'Private row: payroll records'), (2, 'Private row: customer secrets')])
# Persist the rows before closing the file.
connection.commit()
connection.close()"
What does this command do?
- The database connection creates private_rows.db in your current directory.
- The table statements give both tables an integer identifier plus a text message.
- The insert statements add two public examples plus two invented private examples.
- The commit saves every row to disk before the connection closes.
Before you inspect the file, which two table names do you expect Python to print?
- Inspect the saved tables plus their rows by running this command:
python -c "import sqlite3
# Read back the exact tables and rows created above.
connection = sqlite3.connect('private_rows.db')
print('Tables:', [row[0] for row in connection.execute('SELECT name FROM sqlite_master WHERE type = ?', ('table',))])
print('Public rows:', connection.execute('SELECT * FROM public').fetchall())
print('Private rows:', connection.execute('SELECT * FROM private').fetchall())
connection.close()"
What does this check?
- The first query reads the database table catalog.
- The next query reads every row from the public table.
- The final query reads every row from the private table.
You should see Tables: ['private', 'public'] followed by the two public rows. The final line should show both invented private rows.
Database command failed?
- Delete a partial private_rows.db file with your file browser if the command reports existing tables.
- Run the database creation command again after removing the partial file.
- Confirm that your terminal is still in the empty working directory from earlier.
Help me fix the database creation command.
Start Datasette and inspect the tables
Datasette turns the database file into a local web server. Its default address is http://127.0.0.1:8001.
The server process keeps this terminal busy while it runs. Leave it running so the next steps can reuse the same instance.
Before you start the server, which table names do you expect the browser page to list?
- Start the server for private_rows.db by running this command:
datasette private_rows.db -o
What does this command do?
Datasette serves the database on its default local port. The open option launches the local page in your browser.
The terminal remains attached to the server process. Keeping that process active preserves the test environment.
Your browser should open at http://127.0.0.1:8001. You should see both public and private in the table list.
That's your baseline ready: both sample tables are visible through the local Datasette interface.
Page not loading or table missing?
- Return to the terminal if the server process stopped.
- Restart the server with the command directly above.
- Check the database inspection output if either table is absent.
Help me fix the local Datasette page.
✔️ Awesome, I've got everything!
Great. Datasette 0.65.4 is serving private_rows.db with both sample tables visible.
ⓧ I'd like to double check the full code
Your finished setup has these two artifact states.
Datasette version 0.65.4 is installed and serving private_rows.db at http://127.0.0.1:8001.
SQLite database named private_rows containing table public with rows (1, 'This row is public') and (2, 'Anyone can read this'), and table private with rows (1, 'Private row: payroll records') and (2, 'Private row: customer secrets').
Database creation command
The complete database creation command from this step is shown below.
python -c "import sqlite3
# Create the database file in the current directory.
connection = sqlite3.connect('private_rows.db')
# Give both tables the same simple two-column shape.
connection.execute('CREATE TABLE public (id INTEGER PRIMARY KEY, message TEXT)')
connection.execute('CREATE TABLE private (id INTEGER PRIMARY KEY, message TEXT)')
# Add safe public examples and invented private examples.
connection.executemany('INSERT INTO public VALUES (?, ?)', [(1, 'This row is public'), (2, 'Anyone can read this')])
connection.executemany('INSERT INTO private VALUES (?, ?)', [(1, 'Private row: payroll records'), (2, 'Private row: customer secrets')])
# Persist the rows before closing the file.
connection.commit()
connection.close()"
What should this create?
This command produces the complete private_rows.db artifact with both required tables plus all four sample rows.
Server command
The complete server command from this step is shown below.
datasette private_rows.db -o
What should this serve?
This command serves private_rows.db at http://127.0.0.1:8001 through Datasette 0.65.4.
Checkpoint
Datasette version 0.65.4 is installed and serving private_rows.db at http://127.0.0.1:8001.
SQLite database named private_rows containing table public with rows (1, 'This row is public') and (2, 'Anyone can read this'), and table private with rows (1, 'Private row: payroll records') and (2, 'Private row: customer secrets').
Your vulnerable test environment is running with both sample tables. Next up, you will add the rule that protects the private table.
Restrict the Private Table
Your local Datasette instance now serves both tables from private_rows.db. The private rows are still exposed through the standard table route.
A table-level permission in metadata.json establishes the baseline control for Datasette 0.65.4. A clean restart makes the server load that rule.
In this step, get ready to:
- Create metadata.json with a rule that denies access to the private table.
- Restart Datasette with the metadata file loaded.
- Confirm the ordinary private-table request follows the permission rule.
Create the table permission rule
Datasette reads table rules from a JSON metadata file. A table-level allow value of false denies every actor at that table boundary.
- Return to the terminal running the Datasette server.
- Press Ctrl+C to stop the server.
- Create metadata.json beside private_rows.db by running these commands:
cat > metadata.json <<'EOF'
{
"databases": {
"private_rows": {
"tables": {
"private": {
"allow": false
}
}
}
}
}
EOF
cat metadata.json
What Does This Configuration Do?
- The databases object selects the private_rows database.
- The tables object targets the private table.
- The false value denies table access for every actor.
- At table scope, the allow rule controls the view-table permission.
- The final cat command prints the saved file for comparison.
- Confirm metadata.json was saved by comparing the terminal output with the block above.
You should see "allow": false inside the private table object.
File Missing or JSON Different?
- Place the closing EOF at the start of its line.
- Press Ctrl+C if the terminal keeps waiting for more input.
- Run the complete block again to replace the partial file.
- Help me fix my metadata file.
✔️ Awesome, I've got everything!
Your metadata.json file now denies access to the private table.
ⓧ I'd like to double check the full code
Your metadata.json file should look like this:
{
"databases": {
"private_rows": {
"tables": {
"private": {
"allow": false
}
}
}
}
}
Restart Datasette with metadata
The server reads its metadata when it starts. A fresh process applies the new table rule to incoming requests.
- Restart Datasette with metadata.json loaded by running this command:
datasette private_rows.db --metadata metadata.json --port 8001
What Does This Command Do?
- The private_rows.db argument selects your existing database file.
- The --metadata flag loads metadata.json.
- The --port flag keeps the server on port 8001.
- Check the terminal output for the local address http://127.0.0.1:8001.
You should see a startup message showing that Datasette is serving requests at http://127.0.0.1:8001.
Server Did Not Restart?
- Stop any older Datasette process that is still using port 8001.
- Compare metadata.json with the full-file tab if the terminal reports invalid metadata.
- Confirm the startup command includes the --metadata flag.
- Help me restart Datasette with metadata.
Check the ordinary private-table request
The unmodified table route is your control case. It shows whether the permission rule protects the exact private table name.
Before you make the request, do you expect the server to return private rows or refuse access?
- Switch back to the browser page that showed both tables.
- Enter http://127.0.0.1:8001/private_rows/private.json in the browser address bar.
- Press Enter to send the request.
You will see a permission-denied response. The private rows stay out of the response body.
That's the baseline secured. Your normal private-table request now follows the rule in metadata.json.
Still Seeing Private Rows?
- Check that the running server was started with the --metadata flag.
- Confirm the database name in metadata.json is private_rows.
- Confirm the table name in metadata.json is private.
- Help me diagnose why the private table is still visible.
Your direct request is blocked by a table-level permission. Next, you will test whether one encoded newline can slip past that boundary.
Trigger the Newline Bypass
Your metadata.json rule now makes Datasette block the ordinary route to the private table. That gives you a trustworthy baseline for testing the published bug.
The flaw appears when the requested table name ends with a newline. In this step, you will encode that character in a curl request to test whether protected rows escape the rule.
In this step, get ready to:
- Reconfirm that the ordinary private-table request is denied.
- Send a request with a trailing newline in the table name.
- Inspect the altered request's JSON response.
Confirm the normal request stays denied
The normal URL is your control request. Its denied response proves that the permission rule still protects the exact table name.
- Before you retry the ordinary request, predict whether the unchanged URL still respects the permission rule.
- Return to the normal private-table request in your browser at http://127.0.0.1:8001/private_rows/private.json.
You should still see a permission-denied response. Your control request remains protected.
Encode a newline in the table name
URL encoding represents a newline as %0A. This keeps the extra character inside the table name when curl sends the request.
The altered name tests how the permission lookup handles a trailing newline. The database contents remain unchanged.
- Switch back to the terminal in the working directory from earlier.
- Before you send the altered request, predict whether the permission rule will deny it.
- Send the trailing-newline request by running this command:
curl -i 'http://127.0.0.1:8001/private_rows/private%0A.json'
What does this request test?
- The curl command sends the request to your local Datasette server.
- The %0A value inserts a trailing newline after the table name.
- The .json suffix requests the response as JSON.
You will see a successful response containing Private row: payroll records. The response also contains Private row: customer secrets.
You have reproduced the bypass. Datasette 0.65.4 returned rows that metadata.json was meant to protect.
No private rows in the response?
- Check that %0A sits immediately after private.
- Keep the single quotes around the full URL so your shell sends it unchanged.
- Confirm that Datasette 0.65.4 is still serving private_rows.db with metadata.json.
Help me troubleshoot the newline bypass request.
✔️ Awesome, I've got everything!
The normal route remains denied. Your encoded route returns both private rows.
ⓧ I'd like to double check the full code
- Compare your terminal command with this exact request.
curl -i 'http://127.0.0.1:8001/private_rows/private%0A.json'
Why this request matters
This exact request preserves the trailing newline as %0A. On Datasette 0.65.4, it returns the private rows despite the table permission.
You have reproduced the leak with one encoded character. Next, you will connect your local result to the published security advisory.
Review the Security Advisory
Your earlier request turned one encoded newline into a leak from the restricted table. The vulnerable Datasette version 0.65.4 server still shows the gap clearly.
A published security advisory connects your local result to the maintainers' documented scope. It identifies the affected releases.
This step gives you a durable record of the advisory's range. It also captures why version 0.65.5 changes the result.
In this step, get ready to:
- Review the published advisory for the permission bypass.
- Compare the affected range with your local version 0.65.4.
- Record the affected range in SECURITY-NOTES.md.
Read the published advisory
The advisory is the authoritative source for the vulnerability's scope. It tells you whether your installed release falls inside the affected range.
- Open advisory GHSA-h547-rmjf-5m2m in your browser.
- Locate the statement that describes the affected versions.
- Locate the change described for Datasette version 0.65.5.
What Does the Advisory Establish?
- Datasette versions before 0.65.5 are affected.
- Your local version 0.65.4 falls inside that range.
- Datasette version 0.65.5 fixes permission checking so a trailing newline on a table name cannot bypass the table permission rule.
You have done the forensic part. Your local leak now sits inside a published affected range.
Record the affected range and fix
A security note preserves the exact relationship between your test and the advisory. It also gives you a reference for checking the upgrade in the next step.
- Create SECURITY-NOTES.md beside private_rows.db by copying the text below into a new file in your text editor:
Notes for advisory GHSA-h547-rmjf-5m2m: Datasette versions before 0.65.5 are affected; this project used 0.65.4; Datasette 0.65.5 fixes permission checking so a trailing newline on a table name cannot bypass the table permission rule.
Why Record These Three Facts?
- The affected range shows whether the advisory applies to an installed release.
- Version 0.65.4 anchors the note to the release that leaked rows in your test.
- Version 0.65.5 records the release where permission checking changed.
- Save SECURITY-NOTES.md.
- Reopen SECURITY-NOTES.md from the working directory.
You should see the advisory ID beside the affected range. You should also see the local version and the fix summary.
Missing a Finding?
Check that the file is named SECURITY-NOTES.md.
Compare its only line with the code block above. Copy any missing version or fix detail into the file.
Help me check my security advisory note.
✔️ Awesome, I've got everything!
Great. Double check that you saved SECURITY-NOTES.md beside private_rows.db.
ⓧ I'd like to double check the full code
Your complete SECURITY-NOTES.md file should look like this:
Notes for advisory GHSA-h547-rmjf-5m2m: Datasette versions before 0.65.5 are affected; this project used 0.65.4; Datasette 0.65.5 fixes permission checking so a trailing newline on a table name cannot bypass the table permission rule.
Verify your security note
Before you check, which three version facts do you expect the note to preserve?
- Return to SECURITY-NOTES.md in your text editor.
- Check the saved line against the advisory findings above.
You should see 0.65.4 identified as the version used for your leak.
You should see every version before 0.65.5 identified as affected.
You should see 0.65.5 identified as the release that closes the trailing-newline permission bypass.
Solid work. Your note now ties the leak to the advisory's affected range.
Next, you will upgrade to 0.65.5. The same trailing-newline request will show whether the permission gap is closed.
Upgrade and Verify the Fix
Your vulnerable Datasette 0.65.4 server proved that one encoded newline could expose rows protected by metadata.json. That result gives you a precise regression test.
Now you will install Datasette 0.65.5. You will replay the exact request that leaked data to confirm the corrected behavior.
In this step, get ready to:
- Upgrade Datasette to 0.65.5.
- Pin the fixed version in requirements.txt.
- Replay both permission checks against the restarted server.
Upgrade Datasette to 0.65.5
A running server keeps its current package code in memory. Stopping it before the upgrade ensures the restart loads the fixed release.
- Stop the current server by pressing Ctrl+C in the terminal from earlier.
- Install the fixed Datasette release by running this command:
pip install datasette==0.65.5
What Does This Install Do?
The version specifier tells pip to install exactly 0.65.5. This replaces the vulnerable package code with the security-fixed release.
- Confirm the installed Datasette version by running this command:
datasette --version
What Does This Check Prove?
The version check confirms which Datasette executable your terminal uses. It prevents a different Python environment from silently running the older installation.
You should see a version result containing 0.65.5. Good, your terminal now points at the fixed release.
Still Seeing the Older Version?
Confirm that the same Python environment from the earlier steps is active. Repeat the installation command after activating that environment.
Close any server process that still has the vulnerable release loaded before restarting.
Help me diagnose why Datasette still reports the old version.
- Restart the server in the background by running this command:
datasette private_rows.db --metadata metadata.json --port 8001 &
Why Restart in the Background?
This command serves private_rows.db with the permission rule from metadata.json. The port keeps the server at http://127.0.0.1:8001.
The trailing & leaves the server running in the background. Your terminal prompt stays available for the verification commands.
You should see startup output confirming that the server is listening at http://127.0.0.1:8001.
Server Did Not Restart?
A previous Datasette process may still be using the port. Stop that process before repeating the restart command.
Confirm that private_rows.db sits beside metadata.json in the current terminal directory.
Help me restart the fixed Datasette server.
Save the fixed baseline
A local upgrade can disappear during a fresh installation. Your notes plus two small files preserve what changed.
- Return to SECURITY-NOTES.md using the editing method from earlier.
- Find the current advisory line:
Notes for advisory GHSA-h547-rmjf-5m2m: Datasette versions before 0.65.5 are affected; this project used 0.65.4; Datasette 0.65.5 fixes permission checking so a trailing newline on a table name cannot bypass the table permission rule.
Why Change This Line?
The current wording identifies 0.65.4 as the project version. It does not record that the version has now been replaced.
- Replace the entire line with this updated record:
Notes for advisory GHSA-h547-rmjf-5m2m: Datasette versions before 0.65.5 are affected; this project used 0.65.4 before upgrading; Datasette 0.65.5 fixes permission checking so a trailing newline on a table name cannot bypass the table permission rule.
What Does the New Wording Capture?
The updated note preserves 0.65.4 as the former local version. It also records the behavior corrected by 0.65.5.
- Save SECURITY-NOTES.md.
- Re-read the saved line to confirm it includes before upgrading.
- Create permission-checks.sh beside private_rows.db using the same editing method.
- Add both reusable permission checks to the file:
# Check the ordinary private-table path.
curl -i 'http://127.0.0.1:8001/private_rows/private.json'
# Check the encoded trailing-newline path.
curl -i 'http://127.0.0.1:8001/private_rows/private%0A.json'
What Do These Checks Cover?
- The first request tests the ordinary private-table path.
- The second request adds the encoded trailing newline that exposed rows under 0.65.4.
- Save permission-checks.sh.
- Create requirements.txt beside private_rows.db.
- Pin the fixed Datasette release with this line:
datasette==0.65.5
Why Pin the Version?
The pin makes future installations select 0.65.5. This protects the project from returning to the vulnerable release.
- Save requirements.txt.
- Confirm that SECURITY-NOTES.md is saved beside private_rows.db.
- Confirm that permission-checks.sh is saved beside private_rows.db.
- Confirm that requirements.txt is saved beside private_rows.db.
✔️ Awesome, I've got everything!
Great, you have saved the advisory record plus both reusable verification files. Your database permission configuration remains unchanged.
ⓧ I'd like to double check the full code
Your SECURITY-NOTES.md file should contain this line.
Notes for advisory GHSA-h547-rmjf-5m2m: Datasette versions before 0.65.5 are affected; this project used 0.65.4 before upgrading; Datasette 0.65.5 fixes permission checking so a trailing newline on a table name cannot bypass the table permission rule.
What Does This Record Preserve?
The note captures the affected range plus the former project version. It states the permission behavior fixed by 0.65.5.
Your permission-checks.sh file should contain both requests.
# Check the ordinary private-table path.
curl -i 'http://127.0.0.1:8001/private_rows/private.json'
# Check the encoded trailing-newline path.
curl -i 'http://127.0.0.1:8001/private_rows/private%0A.json'
What Does This Script Preserve?
The script keeps the ordinary request beside the regression test. You can replay the same pair after any future permission change.
Your requirements.txt file should contain this version pin.
datasette==0.65.5
What Does This Pin Preserve?
The requirement fixes Datasette at the release that closes the trailing-newline permission bypass.
Replay both permission checks
The normal request confirms the original table rule still works. The encoded request tests whether the published bypass survives the upgrade.
Before you run these checks, predict whether either path can still reveal a private row.
- Run the saved permission checks against the restarted server with this command:
bash permission-checks.sh
What Does This Command Test?
Bash reads the two commands from permission-checks.sh in order. Both requests reach the same server with the same table permission rule.
Both responses should refuse access. Neither response should contain the private table rows.
Still Seeing Private Rows?
- Stop any Datasette process left from the vulnerable run.
- Repeat the version check from the first substep.
- Restart the server with the background command above.
Help me find why the trailing-newline request still returns private rows.
You have closed the exact leak you reproduced earlier. Your saved checks now prove the permission boundary under 0.65.5.
Secret mission
Build a Permission Test Matrix
Add a public control to your existing private-table checks. You will prove Datasette is serving allowed data while both private paths stay denied.
Clean Up Your Resources
Clean Up Your Resources
Your Datasette 0.65.5 server runs only on your machine, so this project has no ongoing cost. Choose whether to keep the permission test available, pause the server, or remove the local files.
Resources you used:
- The local Datasette 0.65.5 server at http://127.0.0.1:8001.
- The local SQLite database private_rows.db.
- The project files metadata.json, SECURITY-NOTES.md, permission-checks.sh, and requirements.txt.
Keep everything running
No action is needed. Choose this option if you want to rerun the normal request and trailing-newline request later.
- Leave the Datasette server running at http://127.0.0.1:8001.
- Keep permission-checks.sh as your reusable permission test.
- Keep SECURITY-NOTES.md as your record of the vulnerability and its fix.
- Keep requirements.txt pinned to datasette==0.65.5 for future installs.
Pause - I'll come back to this later
Pausing frees your terminal while preserving every project file. There is no cost difference because the server runs locally.
- Return to the terminal running Datasette.
- Press Ctrl+C to stop the server.
The terminal returns to its prompt. Your database and permission checks remain in the current directory.
- Leave private_rows.db and the four project files in place for your next session.
Delete - I don't want to use this again
Deleting these files is permanent. The cleanup command targets only the five named project artifacts in your terminal's current directory.
- Return to the terminal running Datasette.
- Press Ctrl+C to stop the server.
The terminal returns to its prompt. The server no longer holds the database open.
- Confirm private_rows.db contains only data you are comfortable deleting.
- Delete the database and project files by running this command:
rm private_rows.db metadata.json SECURITY-NOTES.md permission-checks.sh requirements.txt
What does this command remove?
This removes the five named project files from the terminal's current directory. It leaves the Datasette installation and the pip package cache untouched.
The vulnerable 0.65.4 package can remain cached even though 0.65.5 is installed.
- Confirm the files are gone by running this command:
ls
What should you see?
You should no longer see private_rows.db, metadata.json, SECURITY-NOTES.md, permission-checks.sh, or requirements.txt in the output.
Nice Work!
Nice Work!
You did it! You reproduced a published Datasette permission bypass using a local SQLite database.
You upgraded to 0.65.5 and proved that both requests now keep the private rows protected.
You've learned how to:
- Built a local security lab where metadata.json blocks access to the private table in private_rows.db.
- Reproduced the trailing-newline permission bypass on 0.65.4. Upgraded to 0.65.5 to prove both private-table requests are denied.
- Created a reusable security check by documenting the affected range in SECURITY-NOTES.md. Pinned the fixed release in requirements.txt. Saved both requests in permission-checks.sh.
- Secret Mission: Extended your permission testing through an optional challenge that pushed your security skills further.
Ready to quiz yourself?